server: add OAuth2 authentication routes

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icacb5359f4d05d53d4c1b60cc2c0f4f66a6a6964
This commit is contained in:
raf 2026-02-07 20:04:19 +03:00
commit a9e9599d5b
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
4 changed files with 546 additions and 3 deletions

View file

@ -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))

View file

@ -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<BasicErrorResponseType>,
StandardTokenResponse<oauth2::EmptyExtraTokenFields, BasicTokenType>,
StandardTokenIntrospectionResponse<
oauth2::EmptyExtraTokenFields,
BasicTokenType,
>,
StandardRevocableToken,
StandardErrorResponse<oauth2::RevocationErrorResponseType>,
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<String>,
}
#[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<AppState>) -> 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<AppState>,
headers: axum::http::HeaderMap,
Query(params): Query<OAuthCallbackParams>,
) -> Result<impl IntoResponse, ApiError> {
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(&params.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<GitHubEmailResponse> =
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<AppState> {
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"));
}
}

View file

@ -60,7 +60,8 @@ impl SessionData {
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Config,
pub sessions: Arc<DashMap<String, SessionData>>,
pub pool: PgPool,
pub config: Config,
pub sessions: Arc<DashMap<String, SessionData>>,
pub http_client: reqwest::Client,
}