feat: Implement GitHub OAuth (#72)

* implement GitHub OAuth

* fmt and clippy

* add secrets for GitHub App in workflow

* fix env vars

* use device flow for login instead of callback for better security, add email and username to posthog analytics

* cleanup

* add user details to sentry context

* fixes after rebase

* feedback fixes

* do not allow to press esc to hide github popup

* use oauth app to get user token with full repo access

* use PAT token as a backup for creating PRs

* update github signin box text

* update sign in box styling

* fmt

---------

Co-authored-by: Gabriel Gordon-Hall <ggordonhall@gmail.com>
This commit is contained in:
Anastasiia Solop
2025-07-08 19:32:23 +02:00
committed by GitHub
parent 5368d827ae
commit dedee0f298
17 changed files with 1041 additions and 90 deletions

View File

@@ -47,6 +47,7 @@ sentry-tower = "0.41.0"
sentry-tracing = { version = "0.41.0", features = ["backtrace"] }
reqwest = { version = "0.11", features = ["json"] }
strip-ansi-escapes = "0.2.1"
urlencoding = "2.1.3"
[build-dependencies]
dotenv = "0.15"

View File

@@ -9,6 +9,12 @@ fn main() {
if let Ok(api_endpoint) = std::env::var("POSTHOG_API_ENDPOINT") {
println!("cargo:rustc-env=POSTHOG_API_ENDPOINT={}", api_endpoint);
}
if let Ok(api_key) = std::env::var("GITHUB_APP_ID") {
println!("cargo:rustc-env=GITHUB_APP_ID={}", api_key);
}
if let Ok(api_endpoint) = std::env::var("GITHUB_APP_CLIENT_ID") {
println!("cargo:rustc-env=GITHUB_APP_CLIENT_ID={}", api_endpoint);
}
// Create frontend/dist directory if it doesn't exist
let dist_path = Path::new("../frontend/dist");

View File

@@ -3,6 +3,7 @@ use std::{str::FromStr, sync::Arc};
use axum::{
body::Body,
http::{header, HeaderValue, StatusCode},
middleware::from_fn_with_state,
response::{IntoResponse, Json as ResponseJson, Response},
routing::{get, post},
Json, Router,
@@ -28,7 +29,7 @@ mod utils;
use app_state::AppState;
use execution_monitor::execution_monitor;
use models::{ApiResponse, Config};
use routes::{config, filesystem, health, projects, task_attempts, tasks};
use routes::{auth, config, filesystem, health, projects, task_attempts, tasks};
use services::PrMonitorService;
async fn echo_handler(
@@ -197,7 +198,9 @@ fn main() -> anyhow::Result<()> {
.merge(task_attempts::task_attempts_router())
.merge(filesystem::filesystem_router())
.merge(config::config_router())
.route("/sounds/:filename", get(serve_sound_file)),
.merge(auth::auth_router())
.route("/sounds/:filename", get(serve_sound_file))
.layer(from_fn_with_state(app_state.clone(), auth::sentry_user_context_middleware)),
);
let app = Router::new()

View File

@@ -44,7 +44,10 @@ pub struct EditorConfig {
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct GitHubConfig {
pub pat: Option<String>,
pub token: Option<String>,
pub username: Option<String>,
pub primary_email: Option<String>,
pub default_pr_base: Option<String>,
}
@@ -177,7 +180,10 @@ impl Default for EditorConfig {
impl Default for GitHubConfig {
fn default() -> Self {
Self {
pat: None,
token: None,
username: None,
primary_email: None,
default_pr_base: Some("main".to_string()),
}
}

286
backend/src/routes/auth.rs Normal file
View File

@@ -0,0 +1,286 @@
use axum::{
extract::{Request, State},
middleware::Next,
response::{Json as ResponseJson, Response},
routing::post,
Json, Router,
};
use crate::{app_state::AppState, models::ApiResponse};
pub fn auth_router() -> Router<AppState> {
Router::new()
.route("/auth/github/device/start", post(device_start))
.route("/auth/github/device/poll", post(device_poll))
}
#[derive(serde::Deserialize)]
struct DeviceStartRequest {}
#[derive(serde::Serialize)]
struct DeviceStartResponse {
device_code: String,
user_code: String,
verification_uri: String,
expires_in: u64,
interval: u64,
}
#[derive(serde::Deserialize)]
struct DevicePollRequest {
device_code: String,
}
/// POST /auth/github/device/start
async fn device_start() -> ResponseJson<ApiResponse<DeviceStartResponse>> {
let params = [
("client_id", "Ov23li9bxz3kKfPOIsGm"),
("scope", "user:email,repo"),
];
let client = reqwest::Client::new();
let res = client
.post("https://github.com/login/device/code")
.header("Accept", "application/json")
.form(&params)
.send()
.await;
let res = match res {
Ok(r) => r,
Err(e) => {
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to contact GitHub: {e}")),
});
}
};
let json: serde_json::Value = match res.json().await {
Ok(j) => j,
Err(e) => {
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to parse GitHub response: {e}")),
});
}
};
if let (
Some(device_code),
Some(user_code),
Some(verification_uri),
Some(expires_in),
Some(interval),
) = (
json.get("device_code").and_then(|v| v.as_str()),
json.get("user_code").and_then(|v| v.as_str()),
json.get("verification_uri").and_then(|v| v.as_str()),
json.get("expires_in").and_then(|v| v.as_u64()),
json.get("interval").and_then(|v| v.as_u64()),
) {
ResponseJson(ApiResponse {
success: true,
data: Some(DeviceStartResponse {
device_code: device_code.to_string(),
user_code: user_code.to_string(),
verification_uri: verification_uri.to_string(),
expires_in,
interval,
}),
message: None,
})
} else {
ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("GitHub error: {}", json)),
})
}
}
/// POST /auth/github/device/poll
async fn device_poll(
State(app_state): State<AppState>,
Json(payload): Json<DevicePollRequest>,
) -> ResponseJson<ApiResponse<String>> {
let params = [
("client_id", "Ov23li9bxz3kKfPOIsGm"),
("device_code", payload.device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
];
let client = reqwest::Client::new();
let res = client
.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.form(&params)
.send()
.await;
let res = match res {
Ok(r) => r,
Err(e) => {
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to contact GitHub: {e}")),
});
}
};
let json: serde_json::Value = match res.json().await {
Ok(j) => j,
Err(e) => {
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to parse GitHub response: {e}")),
});
}
};
if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
// Not authorized yet, or other error
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(error.to_string()),
});
}
let access_token = json.get("access_token").and_then(|v| v.as_str());
if let Some(access_token) = access_token {
// Fetch user info
let user_res = client
.get("https://api.github.com/user")
.bearer_auth(access_token)
.header("User-Agent", "vibe-kanban-app")
.send()
.await;
let user_json: serde_json::Value = match user_res {
Ok(res) => match res.json().await {
Ok(json) => json,
Err(e) => {
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to parse GitHub user response: {e}")),
});
}
},
Err(e) => {
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to fetch user info: {e}")),
});
}
};
let username = user_json
.get("login")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Fetch user emails
let emails_res = client
.get("https://api.github.com/user/emails")
.bearer_auth(access_token)
.header("User-Agent", "vibe-kanban-app")
.send()
.await;
let emails_json: serde_json::Value = match emails_res {
Ok(res) => match res.json().await {
Ok(json) => json,
Err(e) => {
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to parse GitHub emails response: {e}")),
});
}
},
Err(e) => {
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to fetch user emails: {e}")),
});
}
};
let primary_email = emails_json
.as_array()
.and_then(|arr| {
arr.iter()
.find(|email| {
email
.get("primary")
.and_then(|v| v.as_bool())
.unwrap_or(false)
})
.and_then(|email| email.get("email").and_then(|v| v.as_str()))
})
.map(|s| s.to_string());
// Save to config
{
let mut config = app_state.get_config().write().await;
config.github.username = username.clone();
config.github.primary_email = primary_email.clone();
config.github.token = Some(access_token.to_string());
let config_path = crate::utils::config_path();
if config.save(&config_path).is_err() {
return ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("Failed to save config".to_string()),
});
}
}
// Identify user in PostHog
let mut props = serde_json::Map::new();
if let Some(ref username) = username {
props.insert(
"username".to_string(),
serde_json::Value::String(username.clone()),
);
}
if let Some(ref email) = primary_email {
props.insert(
"email".to_string(),
serde_json::Value::String(email.clone()),
);
}
{
let props = serde_json::Value::Object(props);
app_state
.track_analytics_event("$identify", Some(props))
.await;
}
ResponseJson(ApiResponse {
success: true,
data: Some("GitHub login successful".to_string()),
message: None,
})
} else {
ResponseJson(ApiResponse {
success: false,
data: None,
message: Some("No access token yet".to_string()),
})
}
}
/// Middleware to set Sentry user context for every request
pub async fn sentry_user_context_middleware(
State(app_state): State<AppState>,
req: Request,
next: Next,
) -> Response {
let config = app_state.get_config().read().await;
let username = config.github.username.clone();
let email = config.github.primary_email.clone();
drop(config);
if username.is_some() || email.is_some() {
sentry::configure_scope(|scope| {
scope.set_user(Some(sentry::User {
username,
email,
..Default::default()
}));
});
}
next.run(req).await
}

View File

@@ -1,3 +1,4 @@
pub mod auth;
pub mod config;
pub mod filesystem;
pub mod health;

View File

@@ -322,8 +322,7 @@ pub async fn create_github_pr(
success: false,
data: None,
message: Some(
"GitHub token not configured. Please set your GitHub token in settings."
.to_string(),
"GitHub authentication not configured. Please sign in with GitHub.".to_string(),
),
}));
}
@@ -358,7 +357,7 @@ pub async fn create_github_pr(
attempt_id,
task_id,
project_id,
github_token: &github_token,
github_token: &config.github.pat.unwrap_or(github_token),
title: &request.title,
body: request.body.as_deref(),
base_branch: Some(&base_branch),
@@ -390,10 +389,23 @@ pub async fn create_github_pr(
attempt_id,
e
);
let message = match &e {
crate::models::task_attempt::TaskAttemptError::Git(err)
if err.message().contains("status code: 403") =>
{
Some("insufficient_github_permissions".to_string())
}
crate::models::task_attempt::TaskAttemptError::Git(err)
if err.message().contains("status code: 404") =>
{
Some("github_repo_not_found_or_no_access".to_string())
}
_ => Some(format!("Failed to create PR: {}", e)),
};
Ok(ResponseJson(ApiResponse {
success: false,
data: None,
message: Some(format!("Failed to create PR: {}", e)),
message,
}))
}
}

View File

@@ -56,21 +56,28 @@ impl AnalyticsService {
self.config.posthog_api_endpoint.trim_end_matches('/')
);
let mut event_properties = properties.unwrap_or_else(|| json!({}));
if let Some(props) = event_properties.as_object_mut() {
props.insert(
"timestamp".to_string(),
json!(chrono::Utc::now().to_rfc3339()),
);
props.insert("version".to_string(), json!(env!("CARGO_PKG_VERSION")));
}
let payload = json!({
let mut payload = json!({
"api_key": self.config.posthog_api_key,
"event": event_name,
"distinct_id": user_id,
"properties": event_properties
});
if event_name == "$identify" {
// For $identify, set person properties in $set
if let Some(props) = properties {
payload["$set"] = props;
}
} else {
// For other events, use properties as before
let mut event_properties = properties.unwrap_or_else(|| json!({}));
if let Some(props) = event_properties.as_object_mut() {
props.insert(
"timestamp".to_string(),
json!(chrono::Utc::now().to_rfc3339()),
);
props.insert("version".to_string(), json!(env!("CARGO_PKG_VERSION")));
}
payload["properties"] = event_properties;
}
let client = self.client.clone();
let event_name = event_name.to_string();

View File

@@ -53,7 +53,11 @@ impl PrMonitorService {
// Get GitHub token from config
let github_token = {
let config_read = config.read().await;
config_read.github.token.clone()
if config_read.github.pat.is_some() {
config_read.github.pat.clone()
} else {
config_read.github.token.clone()
}
};
match github_token {