server: add OAuth2 authentication routes
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Icacb5359f4d05d53d4c1b60cc2c0f4f66a6a6964
This commit is contained in:
parent
2eae49f313
commit
a9e9599d5b
4 changed files with 546 additions and 3 deletions
|
|
@ -22,6 +22,7 @@ hmac.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
subtle.workspace = true
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
@ -31,6 +32,8 @@ tower-http.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
oauth2.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
|
||||||
# Our crates
|
# Our crates
|
||||||
fc-common.workspace = true
|
fc-common.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ pub mod health;
|
||||||
pub mod jobsets;
|
pub mod jobsets;
|
||||||
pub mod logs;
|
pub mod logs;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
pub mod oauth;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|
@ -156,6 +157,8 @@ pub fn router(state: AppState, config: &ServerConfig) -> Router {
|
||||||
.merge(metrics::router())
|
.merge(metrics::router())
|
||||||
// Webhooks use their own HMAC auth, outside the API key gate
|
// Webhooks use their own HMAC auth, outside the API key gate
|
||||||
.merge(webhooks::router())
|
.merge(webhooks::router())
|
||||||
|
// OAuth routes use their own auth mechanism
|
||||||
|
.merge(oauth::router())
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(cors_layer)
|
.layer(cors_layer)
|
||||||
.layer(RequestBodyLimitLayer::new(config.max_body_size))
|
.layer(RequestBodyLimitLayer::new(config.max_body_size))
|
||||||
|
|
|
||||||
536
crates/server/src/routes/oauth.rs
Normal file
536
crates/server/src/routes/oauth.rs
Normal 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(¶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<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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,4 +63,5 @@ pub struct AppState {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub sessions: Arc<DashMap<String, SessionData>>,
|
pub sessions: Arc<DashMap<String, SessionData>>,
|
||||||
|
pub http_client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue