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:
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
286
backend/src/routes/auth.rs
Normal 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(¶ms)
|
||||
.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(¶ms)
|
||||
.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
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod filesystem;
|
||||
pub mod health;
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user