diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 4cc4050..10c2b55 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -22,6 +22,7 @@ hmac.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true +subtle.workspace = true sqlx.workspace = true thiserror.workspace = true tokio.workspace = true @@ -31,6 +32,8 @@ tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true uuid.workspace = true +oauth2.workspace = true +reqwest.workspace = true # Our crates fc-common.workspace = true diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index bb7a807..191a373 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -10,6 +10,7 @@ pub mod health; pub mod jobsets; pub mod logs; pub mod metrics; +pub mod oauth; pub mod projects; pub mod search; pub mod users; @@ -156,6 +157,8 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router { .merge(metrics::router()) // Webhooks use their own HMAC auth, outside the API key gate .merge(webhooks::router()) + // OAuth routes use their own auth mechanism + .merge(oauth::router()) .layer(TraceLayer::new_for_http()) .layer(cors_layer) .layer(RequestBodyLimitLayer::new(config.max_body_size)) diff --git a/crates/server/src/routes/oauth.rs b/crates/server/src/routes/oauth.rs new file mode 100644 index 0000000..be3700c --- /dev/null +++ b/crates/server/src/routes/oauth.rs @@ -0,0 +1,536 @@ +//! OAuth authentication routes + +use axum::{ + Router, + extract::{Query, State}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, + routing::get, +}; +use fc_common::{config::GitHubOAuthConfig, models::UserType, repo}; +use oauth2::{ + AuthUrl, + AuthorizationCode, + ClientId, + ClientSecret, + CsrfToken, + EndpointNotSet, + EndpointSet, + RedirectUrl, + Scope, + StandardErrorResponse, + StandardRevocableToken, + StandardTokenIntrospectionResponse, + StandardTokenResponse, + TokenResponse, + TokenUrl, + basic::{BasicClient, BasicErrorResponseType, BasicTokenType}, +}; +use serde::Deserialize; + +use crate::{error::ApiError, state::AppState}; + +/// Type alias for the fully-configured GitHub OAuth client (oauth2 v5.0 +/// type-state) +type GitHubOAuthClient = oauth2::Client< + StandardErrorResponse, + StandardTokenResponse, + StandardTokenIntrospectionResponse< + oauth2::EmptyExtraTokenFields, + BasicTokenType, + >, + StandardRevocableToken, + StandardErrorResponse, + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointSet, +>; + +#[derive(Debug, Deserialize)] +pub struct OAuthCallbackParams { + code: String, + state: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubUserResponse { + id: i64, + login: String, + #[allow(dead_code)] + avatar_url: Option, +} + +#[derive(Debug, Deserialize)] +struct GitHubEmailResponse { + email: String, + primary: bool, + verified: bool, +} + +fn build_github_client(config: &GitHubOAuthConfig) -> GitHubOAuthClient { + let auth_url = + AuthUrl::new("https://github.com/login/oauth/authorize".to_string()) + .expect("valid auth url"); + let token_url = + TokenUrl::new("https://github.com/login/oauth/access_token".to_string()) + .expect("valid token url"); + + // oauth2 v5.0 uses builder pattern with type-state + BasicClient::new(ClientId::new(config.client_id.clone())) + .set_client_secret(ClientSecret::new(config.client_secret.clone())) + .set_auth_uri(auth_url) + .set_token_uri(token_url) + .set_redirect_uri( + RedirectUrl::new(config.redirect_uri.clone()) + .expect("valid redirect url"), + ) +} + +async fn github_login(State(state): State) -> impl IntoResponse { + let config = match &state.config.oauth.github { + Some(c) => c, + None => { + return (StatusCode::NOT_FOUND, "GitHub OAuth not configured") + .into_response(); + }, + }; + + let client = build_github_client(config); + let (auth_url, csrf_token) = client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("read:user".to_string())) + .add_scope(Scope::new("user:email".to_string())) + .url(); + + // Store CSRF token in a cookie for verification + // Add Secure flag when using HTTPS (detected via redirect_uri) + let secure_flag = if config.redirect_uri.starts_with("https://") { + "; Secure" + } else { + "" + }; + let cookie = format!( + "fc_oauth_state={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600{}", + csrf_token.secret(), + secure_flag + ); + + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, auth_url.as_str()) + .header(header::SET_COOKIE, cookie) + .body(axum::body::Body::empty()) + .unwrap() + .into_response() +} + +async fn github_callback( + State(state): State, + headers: axum::http::HeaderMap, + Query(params): Query, +) -> Result { + let config = match &state.config.oauth.github { + Some(c) => c, + None => { + return Err(ApiError(fc_common::CiError::NotFound( + "GitHub OAuth not configured".to_string(), + ))); + }, + }; + + // Verify CSRF token from cookie + let stored_state = headers + .get(header::COOKIE) + .and_then(|c| c.to_str().ok()) + .and_then(|cookies| { + cookies.split(';').find_map(|c| { + let c = c.trim(); + c.strip_prefix("fc_oauth_state=") + }) + }); + + if stored_state != Some(¶ms.state) { + return Err(ApiError(fc_common::CiError::Unauthorized( + "Invalid OAuth state".to_string(), + ))); + } + + let client = build_github_client(config); + + // Create HTTP client for oauth2 v5.0 token exchange + let http_client = oauth2::reqwest::ClientBuilder::new() + .redirect(oauth2::reqwest::redirect::Policy::none()) + .build() + .map_err(|e| { + ApiError(fc_common::CiError::Internal(format!( + "Failed to create HTTP client: {e}" + ))) + })?; + + // Exchange code for access token + let token_result = client + .exchange_code(AuthorizationCode::new(params.code)) + .request_async(&http_client) + .await + .map_err(|e| { + ApiError(fc_common::CiError::Internal(format!( + "Token exchange failed: {e}" + ))) + })?; + + let access_token = token_result.access_token().secret(); + + // Fetch user info from GitHub using shared HTTP client + let user_response = state + .http_client + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {access_token}")) + .header("User-Agent", "FC-CI") + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| { + ApiError(fc_common::CiError::Internal(format!( + "GitHub API request failed: {e}" + ))) + })?; + + if !user_response.status().is_success() { + return Err(ApiError(fc_common::CiError::Internal(format!( + "GitHub API returned status: {}", + user_response.status() + )))); + } + + let user_info: GitHubUserResponse = + user_response.json().await.map_err(|e| { + ApiError(fc_common::CiError::Internal(format!( + "Failed to parse GitHub user: {e}" + ))) + })?; + + // Fetch user emails + let emails_response = state + .http_client + .get("https://api.github.com/user/emails") + .header("Authorization", format!("Bearer {access_token}")) + .header("User-Agent", "FC-CI") + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| { + ApiError(fc_common::CiError::Internal(format!( + "GitHub emails API failed: {e}" + ))) + })?; + + if !emails_response.status().is_success() { + return Err(ApiError(fc_common::CiError::Internal(format!( + "GitHub emails API returned status: {}", + emails_response.status() + )))); + } + + let emails: Vec = + emails_response.json().await.map_err(|e| { + ApiError(fc_common::CiError::Internal(format!( + "Failed to parse GitHub emails: {e}" + ))) + })?; + + let primary_email = emails + .iter() + .find(|e| e.primary && e.verified) + .or_else(|| emails.iter().find(|e| e.verified)) + .map(|e| e.email.clone()); + + // Create or update user in database + let user = repo::users::upsert_oauth_user( + &state.pool, + &user_info.login, + primary_email.as_deref(), + UserType::Github, + &user_info.id.to_string(), + ) + .await + .map_err(ApiError)?; + + // Create session + let session = repo::users::create_session(&state.pool, user.id) + .await + .map_err(ApiError)?; + + // Clear OAuth state cookie and set session cookie + // Add Secure flag when using HTTPS (detected via redirect_uri) + let secure_flag = if config.redirect_uri.starts_with("https://") { + "; Secure" + } else { + "" + }; + let clear_state = format!( + "fc_oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", + secure_flag + ); + let session_cookie = format!( + "fc_user_session={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", + session.0, + 7 * 24 * 60 * 60, // 7 days + secure_flag + ); + + Ok( + Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, "/") + .header(header::SET_COOKIE, clear_state) + .header(header::SET_COOKIE, session_cookie) + .body(axum::body::Body::empty()) + .unwrap(), + ) +} + +pub fn router() -> Router { + Router::new() + .route("/api/v1/auth/github", get(github_login)) + .route("/api/v1/auth/github/callback", get(github_callback)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_github_client() { + let config = GitHubOAuthConfig { + client_id: "test_client_id".to_string(), + client_secret: "test_client_secret".to_string(), + redirect_uri: "http://localhost:3000/api/v1/auth/github/callback" + .to_string(), + }; + + // Should not panic + let _client = build_github_client(&config); + } + + #[test] + fn test_build_github_client_https() { + let config = GitHubOAuthConfig { + client_id: "test_client_id".to_string(), + client_secret: "test_client_secret".to_string(), + redirect_uri: "https://example.com/api/v1/auth/github/callback" + .to_string(), + }; + + // Should not panic with HTTPS redirect URI + let _client = build_github_client(&config); + } + + #[test] + fn test_authorize_url_generation() { + let config = GitHubOAuthConfig { + client_id: "test_client_id".to_string(), + client_secret: "test_client_secret".to_string(), + redirect_uri: "http://localhost:3000/api/v1/auth/github/callback" + .to_string(), + }; + + let client = build_github_client(&config); + let (auth_url, csrf_token) = client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("read:user".to_string())) + .url(); + + let url_str = auth_url.as_str(); + assert!(url_str.starts_with("https://github.com/login/oauth/authorize")); + assert!(url_str.contains("client_id=test_client_id")); + assert!(url_str.contains("scope=read%3Auser")); + assert!(!csrf_token.secret().is_empty()); + } + + #[test] + fn test_secure_flag_detection() { + // HTTP should not have Secure flag + let http_uri = "http://localhost:3000/callback"; + let secure_flag = if http_uri.starts_with("https://") { + "; Secure" + } else { + "" + }; + assert_eq!(secure_flag, ""); + + // HTTPS should have Secure flag + let https_uri = "https://example.com/callback"; + let secure_flag = if https_uri.starts_with("https://") { + "; Secure" + } else { + "" + }; + assert_eq!(secure_flag, "; Secure"); + } + + #[test] + fn test_oauth_callback_params_deserialize() { + let json = r#"{"code": "abc123", "state": "xyz789"}"#; + let params: OAuthCallbackParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.code, "abc123"); + assert_eq!(params.state, "xyz789"); + } + + #[test] + fn test_github_user_response_deserialize() { + let json = r#"{ + "id": 12345, + "login": "testuser", + "avatar_url": "https://avatars.githubusercontent.com/u/12345" + }"#; + let user: GitHubUserResponse = serde_json::from_str(json).unwrap(); + assert_eq!(user.id, 12345); + assert_eq!(user.login, "testuser"); + assert_eq!( + user.avatar_url, + Some("https://avatars.githubusercontent.com/u/12345".to_string()) + ); + } + + #[test] + fn test_github_user_response_minimal() { + // avatar_url is optional + let json = r#"{"id": 12345, "login": "testuser", "avatar_url": null}"#; + let user: GitHubUserResponse = serde_json::from_str(json).unwrap(); + assert_eq!(user.id, 12345); + assert_eq!(user.login, "testuser"); + assert!(user.avatar_url.is_none()); + } + + #[test] + fn test_github_email_response_deserialize() { + let json = r#"{ + "email": "user@example.com", + "primary": true, + "verified": true + }"#; + let email: GitHubEmailResponse = serde_json::from_str(json).unwrap(); + assert_eq!(email.email, "user@example.com"); + assert!(email.primary); + assert!(email.verified); + } + + #[test] + fn test_github_emails_find_primary_verified() { + let emails = vec![ + GitHubEmailResponse { + email: "secondary@example.com".to_string(), + primary: false, + verified: true, + }, + GitHubEmailResponse { + email: "primary@example.com".to_string(), + primary: true, + verified: true, + }, + GitHubEmailResponse { + email: "unverified@example.com".to_string(), + primary: false, + verified: false, + }, + ]; + + let primary_email = emails + .iter() + .find(|e| e.primary && e.verified) + .or_else(|| emails.iter().find(|e| e.verified)) + .map(|e| e.email.clone()); + + assert_eq!(primary_email, Some("primary@example.com".to_string())); + } + + #[test] + fn test_github_emails_fallback_to_verified() { + // No primary email, should fall back to first verified + let emails = vec![ + GitHubEmailResponse { + email: "unverified@example.com".to_string(), + primary: false, + verified: false, + }, + GitHubEmailResponse { + email: "verified@example.com".to_string(), + primary: false, + verified: true, + }, + ]; + + let primary_email = emails + .iter() + .find(|e| e.primary && e.verified) + .or_else(|| emails.iter().find(|e| e.verified)) + .map(|e| e.email.clone()); + + assert_eq!(primary_email, Some("verified@example.com".to_string())); + } + + #[test] + fn test_github_emails_no_verified() { + // No verified emails + let emails = vec![GitHubEmailResponse { + email: "unverified@example.com".to_string(), + primary: true, + verified: false, + }]; + + let primary_email = emails + .iter() + .find(|e| e.primary && e.verified) + .or_else(|| emails.iter().find(|e| e.verified)) + .map(|e| e.email.clone()); + + assert!(primary_email.is_none()); + } + + #[test] + fn test_cookie_parsing() { + // Simulate parsing cookies to find OAuth state + let cookie_header = + "other_cookie=value; fc_oauth_state=abc123; another=xyz"; + + let stored_state = cookie_header.split(';').find_map(|c| { + let c = c.trim(); + c.strip_prefix("fc_oauth_state=") + }); + + assert_eq!(stored_state, Some("abc123")); + } + + #[test] + fn test_cookie_parsing_not_found() { + let cookie_header = "other_cookie=value; another=xyz"; + + let stored_state = cookie_header.split(';').find_map(|c| { + let c = c.trim(); + c.strip_prefix("fc_oauth_state=") + }); + + assert!(stored_state.is_none()); + } + + #[test] + fn test_session_cookie_format() { + let session_token = "test-session-token"; + let secure_flag = "; Secure"; + let max_age = 7 * 24 * 60 * 60; + + let cookie = format!( + "fc_user_session={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", + session_token, max_age, secure_flag + ); + + assert!(cookie.contains("fc_user_session=test-session-token")); + assert!(cookie.contains("HttpOnly")); + assert!(cookie.contains("SameSite=Lax")); + assert!(cookie.contains("Path=/")); + assert!(cookie.contains("Max-Age=604800")); // 7 days in seconds + assert!(cookie.contains("Secure")); + } +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index d3b7d10..679aadf 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -60,7 +60,8 @@ impl SessionData { #[derive(Clone)] pub struct AppState { - pub pool: PgPool, - pub config: Config, - pub sessions: Arc>, + pub pool: PgPool, + pub config: Config, + pub sessions: Arc>, + pub http_client: reqwest::Client, }