diff --git a/lib/apps/fabro-server/src/auth/cli_flow.rs b/lib/apps/fabro-server/src/auth/cli_flow.rs index 142dd7b66..21d684816 100644 --- a/lib/apps/fabro-server/src/auth/cli_flow.rs +++ b/lib/apps/fabro-server/src/auth/cli_flow.rs @@ -28,7 +28,8 @@ use url::{Host, Url}; use crate::auth::browser_shell::browser_shell; use crate::auth::{ - self, AuthCode, AuthErrorCode, ConsumeOutcome, JwtSubject, REFRESH_TOKEN_PREFIX, RefreshToken, + self, AuthCode, AuthErrorCode, AuthSessionRecord, JwtSubject, REFRESH_TOKEN_PREFIX, + RefreshToken, RotateOutcome, }; use crate::jwt_auth::{AuthMode, ConfiguredAuth, bearer_token_from_headers}; use crate::principal_middleware::{ @@ -466,32 +467,30 @@ async fn token( let refresh_expires_at = now + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS); let refresh_secret = random_secret(); let refresh_token = format!("{REFRESH_TOKEN_PREFIX}{refresh_secret}"); - let refresh_row = RefreshToken { - token_hash: hash_refresh_secret(&refresh_secret), - chain_id: uuid::Uuid::new_v4(), + let session = AuthSessionRecord { + id: uuid::Uuid::new_v4(), identity: entry.identity.clone(), login: entry.login.clone(), name: entry.name.clone(), email: entry.email.clone(), avatar_url: entry.avatar_url.clone(), - issued_at: now, - expires_at: refresh_expires_at, - last_used_at: now, - used: false, user_agent: sanitize_user_agent(request_user_agent(&headers)), + created_at: now, + last_used_at: now, }; - let auth_tokens = match state.store_ref().refresh_tokens().await { - Ok(store) => store, - Err(err) => { - warn!(error = %err, "Failed to open refresh token store"); - return oauth_error( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "Could not complete authentication", - ); - } + let refresh_row = RefreshToken { + token_hash: hash_refresh_secret(&refresh_secret), + session_id: session.id, + issued_at: now, + expires_at: refresh_expires_at, + used_at: None, }; - if let Err(err) = auth_tokens.insert_refresh_token(refresh_row.clone()).await { + if let Err(err) = state + .stores + .auth_sessions + .create_session(&session, &refresh_row) + .await + { warn!(error = %err, "Failed to persist refresh token"); return oauth_error( StatusCode::INTERNAL_SERVER_ERROR, @@ -516,7 +515,7 @@ async fn token( ); log_cli_auth_tokens_issued(&entry.login, &entry.email); - auth_slot.replace(refresh_user_context(&refresh_row)); + auth_slot.replace(refresh_user_context(&session)); Json(CliTokenResponse { access_token, @@ -524,10 +523,10 @@ async fn token( refresh_token, refresh_token_expires_at: refresh_expires_at, subject: subject_response( - &refresh_row.identity, - &refresh_row.login, - &refresh_row.name, - &refresh_row.email, + &session.identity, + &session.login, + &session.name, + &session.email, ), }) .into_response() @@ -570,37 +569,19 @@ async fn refresh( "Could not refresh authentication", ); }; - let auth_tokens = match state.store_ref().refresh_tokens().await { - Ok(store) => store, - Err(err) => { - warn!(error = %err, "Failed to open refresh token store"); - return oauth_error( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "Could not refresh authentication", - ); - } - }; + let auth_sessions = &state.stores.auth_sessions; let now = chrono::Utc::now(); let secret_hash = hash_refresh_secret(&secret); - let existing = match auth_tokens.find_refresh_token(&secret_hash).await { - Ok(existing) => existing, - Err(err) => { - warn!(error = %err, "Failed to load refresh token before rotation"); - return oauth_error( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "Could not refresh authentication", - ); - } - }; let next_secret = random_secret(); let next_user_agent = sanitize_user_agent(request_user_agent(&headers)); - let outcome = match auth_tokens - .consume_and_rotate( - secret_hash, - next_refresh_row(existing.as_ref(), &next_secret, &next_user_agent, now), + let refresh_expires_at = now + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS); + let outcome = match auth_sessions + .rotate( + &secret_hash, + &hash_refresh_secret(&next_secret), + refresh_expires_at, + &next_user_agent, now, ) .await @@ -616,42 +597,34 @@ async fn refresh( } }; - let (old, new_row) = match outcome { - ConsumeOutcome::NotFound | ConsumeOutcome::Expired => { + let session = match outcome { + RotateOutcome::NotFound | RotateOutcome::Expired => { auth_slot.replace(RequestAuthContext::invalid()); - if auth_tokens.was_recently_replay_revoked(&secret_hash, now) { - return oauth_error( - StatusCode::UNAUTHORIZED, - "refresh_token_revoked", - "Refresh token revoked", - ); - } return oauth_error( StatusCode::UNAUTHORIZED, "refresh_token_expired", "Refresh token expired", ); } - ConsumeOutcome::Reused(old) => { + RotateOutcome::Reused(session) => { auth_slot.replace(RequestAuthContext::invalid()); - auth_tokens.mark_refresh_token_replay(secret_hash, now); - if let Err(err) = auth_tokens.delete_chain(old.chain_id).await { - warn!(error = %err, chain_id = %old.chain_id, "Failed to revoke replayed refresh token chain"); + if let Err(err) = auth_sessions.delete_session(session.id).await { + warn!(error = %err, session_id = %session.id, "Failed to revoke replayed refresh token chain"); } - log_refresh_token_replay(old.chain_id, old.identity.subject(), &next_user_agent); + log_refresh_token_replay(session.id, session.identity.subject(), &next_user_agent); return oauth_error( StatusCode::UNAUTHORIZED, "refresh_token_revoked", "Refresh token revoked", ); } - ConsumeOutcome::Rotated(old, new_row) => (old, *new_row), + RotateOutcome::Rotated(session) => session, }; - if !login_allowed(state.as_ref(), &old.login) { + if !login_allowed(state.as_ref(), &session.login) { auth_slot.replace(RequestAuthContext::invalid()); - if let Err(err) = auth_tokens.delete_chain(old.chain_id).await { - warn!(error = %err, chain_id = %old.chain_id, "Failed to revoke deauthorized refresh token chain"); + if let Err(err) = auth_sessions.delete_session(session.id).await { + warn!(error = %err, session_id = %session.id, "Failed to revoke deauthorized refresh token chain"); } return oauth_error(StatusCode::FORBIDDEN, "unauthorized", "Login not permitted"); } @@ -661,24 +634,29 @@ async fn refresh( jwt_key, jwt_issuer, &JwtSubject { - identity: old.identity.clone(), - login: old.login.clone(), - name: old.name.clone(), - email: old.email.clone(), - avatar_url: old.avatar_url.clone(), + identity: session.identity.clone(), + login: session.login.clone(), + name: session.name.clone(), + email: session.email.clone(), + avatar_url: session.avatar_url.clone(), user_url: String::new(), auth_method: AuthMethod::Github, }, chrono::Duration::minutes(ACCESS_TOKEN_TTL_MINUTES), ); - auth_slot.replace(refresh_user_context(&old)); + auth_slot.replace(refresh_user_context(&session)); Json(CliTokenResponse { access_token, access_token_expires_at: access_expires_at, refresh_token: format!("{REFRESH_TOKEN_PREFIX}{next_secret}"), - refresh_token_expires_at: new_row.expires_at, - subject: subject_response(&old.identity, &old.login, &old.name, &old.email), + refresh_token_expires_at: refresh_expires_at, + subject: subject_response( + &session.identity, + &session.login, + &session.name, + &session.email, + ), }) .into_response() } @@ -701,20 +679,9 @@ async fn logout( } RefreshCredential::Present(secret) => secret, }; - let auth_tokens = match state.store_ref().refresh_tokens().await { - Ok(store) => store, - Err(err) => { - warn!(error = %err, "Failed to open refresh token store"); - return oauth_error( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "Could not complete logout", - ); - } - }; - - let existing = match auth_tokens - .find_refresh_token(&hash_refresh_secret(&secret)) + let auth_sessions = &state.stores.auth_sessions; + let existing = match auth_sessions + .find_session_by_token_hash(&hash_refresh_secret(&secret)) .await { Ok(existing) => existing, @@ -728,17 +695,17 @@ async fn logout( } }; - if let Some(refresh_token) = existing { - auth_slot.replace(refresh_user_context(&refresh_token)); - if let Err(err) = auth_tokens.delete_chain(refresh_token.chain_id).await { - warn!(error = %err, chain_id = %refresh_token.chain_id, "Failed to revoke refresh token chain during logout"); + if let Some(session) = existing { + auth_slot.replace(refresh_user_context(&session)); + if let Err(err) = auth_sessions.delete_session(session.id).await { + warn!(error = %err, session_id = %session.id, "Failed to revoke refresh token chain during logout"); return oauth_error( StatusCode::INTERNAL_SERVER_ERROR, "server_error", "Could not complete logout", ); } - log_cli_refresh_chain_logged_out(&refresh_token.login, &refresh_token.email); + log_cli_refresh_chain_logged_out(&session.login, &session.email); } else { auth_slot.replace(RequestAuthContext::invalid()); } @@ -956,13 +923,13 @@ fn refresh_credential_from_headers(headers: &HeaderMap) -> RefreshCredential { } } -fn refresh_user_context(refresh_token: &RefreshToken) -> RequestAuthContext { +fn refresh_user_context(session: &AuthSessionRecord) -> RequestAuthContext { RequestAuthContext::authenticated( Principal::user_with_avatar( - refresh_token.identity.clone(), - refresh_token.login.clone(), + session.identity.clone(), + session.login.clone(), AuthMethod::Github, - non_empty_avatar_url(&refresh_token.avatar_url), + non_empty_avatar_url(&session.avatar_url), ), None, ) @@ -972,31 +939,6 @@ fn hash_refresh_secret(secret: &str) -> [u8; 32] { Sha256::digest(secret.as_bytes()).into() } -fn next_refresh_row( - existing: Option<&RefreshToken>, - next_secret: &str, - user_agent: &str, - now: chrono::DateTime, -) -> RefreshToken { - let fallback_identity = fabro_types::IdpIdentity::new("https://github.com", "0") - .expect("static identity should be valid"); - RefreshToken { - token_hash: hash_refresh_secret(next_secret), - chain_id: existing.map_or_else(uuid::Uuid::new_v4, |token| token.chain_id), - identity: existing - .map_or_else(|| fallback_identity.clone(), |token| token.identity.clone()), - login: existing.map_or_else(String::new, |token| token.login.clone()), - name: existing.map_or_else(String::new, |token| token.name.clone()), - email: existing.map_or_else(String::new, |token| token.email.clone()), - avatar_url: existing.map_or_else(String::new, |token| token.avatar_url.clone()), - issued_at: now, - expires_at: now + chrono::Duration::days(REFRESH_TOKEN_TTL_DAYS), - last_used_at: now, - used: false, - user_agent: user_agent.to_string(), - } -} - fn user_agent_fingerprint(user_agent: &str) -> String { let digest = Sha256::digest(user_agent.as_bytes()); hex::encode(&digest[..8]) @@ -1006,9 +948,9 @@ fn log_cli_auth_tokens_issued(login: &str, email: &str) { info!(login = %login, email = %email, "Issued CLI auth tokens"); } -fn log_refresh_token_replay(chain_id: uuid::Uuid, idp_subject: &str, user_agent: &str) { +fn log_refresh_token_replay(session_id: uuid::Uuid, idp_subject: &str, user_agent: &str) { warn!( - chain_id = %chain_id, + session_id = %session_id, idp_subject = %idp_subject, user_agent_fingerprint = %user_agent_fingerprint(user_agent), "Refresh token replay detected" @@ -1248,9 +1190,10 @@ mod tests { CliFlowCookie, DEV_TOKEN_LOGIN_INSTRUCTIONS, add_cli_flow_cookie, read_private_cli_flow, user_agent_fingerprint, web_routes, }; - use crate::auth::{self, AuthCode, AuthErrorCode, RefreshToken}; + use crate::auth::{self, AuthCode, AuthErrorCode, AuthSessionRecord, RefreshToken}; use crate::jwt_auth::{AuthMode, ConfiguredAuth}; use crate::principal_middleware::{AuthStatus, RequestAuthContext}; + use crate::server::AppState; use crate::web_auth::SessionCookie; fn test_cookie_key() -> Key { @@ -1413,23 +1356,39 @@ client_id = "github-client-id" Sha256::digest(secret.as_bytes()).into() } - fn refresh_row(secret: &str) -> RefreshToken { + fn session_and_token(secret: &str) -> (AuthSessionRecord, RefreshToken) { let now = chrono::Utc::now(); - RefreshToken { - token_hash: hash_refresh_secret(secret), - chain_id: Uuid::new_v4(), + let session = AuthSessionRecord { + id: Uuid::new_v4(), identity: fabro_types::IdpIdentity::new("https://github.com", "12345") .expect("identity should be valid"), login: "octocat".to_string(), name: "The Octocat".to_string(), email: "octocat@example.com".to_string(), avatar_url: "https://example.com/octocat.png".to_string(), - issued_at: now, - expires_at: now + chrono::Duration::days(30), - last_used_at: now, - used: false, user_agent: "fabro-test".to_string(), - } + created_at: now, + last_used_at: now, + }; + let token = RefreshToken { + token_hash: hash_refresh_secret(secret), + session_id: session.id, + issued_at: now, + expires_at: now + chrono::Duration::days(30), + used_at: None, + }; + (session, token) + } + + async fn open_cli_session(state: &AppState, secret: &str) -> Uuid { + let (session, token) = session_and_token(secret); + state + .stores + .auth_sessions + .create_session(&session, &token) + .await + .unwrap(); + session.id } #[derive(Default)] @@ -2014,9 +1973,9 @@ client_id = "github-client-id" .unwrap() .strip_prefix("fabro_refresh_") .unwrap(); - let auth_tokens = state.store_ref().refresh_tokens().await.unwrap(); - let refresh = auth_tokens - .find_refresh_token(&hash_refresh_secret(refresh_secret)) + let auth_sessions = &state.stores.auth_sessions; + let refresh = auth_sessions + .find_session_by_token_hash(&hash_refresh_secret(refresh_secret)) .await .unwrap() .expect("refresh token should be stored"); @@ -2168,11 +2127,8 @@ client_id = "github-client-id" async fn refresh_rotates_tokens_and_replay_revokes_chain() { let (app, state) = test_router(github_settings("https://fabro.example")); let initial_secret = "refresh-secret-1"; - let auth_tokens = state.store_ref().refresh_tokens().await.unwrap(); - auth_tokens - .insert_refresh_token(refresh_row(initial_secret)) - .await - .unwrap(); + open_cli_session(&state, initial_secret).await; + let auth_sessions = &state.stores.auth_sessions; let refresh_request = || { Request::builder() @@ -2211,15 +2167,15 @@ client_id = "github-client-id" let new_secret = rotated.strip_prefix("fabro_refresh_").unwrap(); assert!( - auth_tokens - .find_refresh_token(&hash_refresh_secret(initial_secret)) + auth_sessions + .find_session_by_token_hash(&hash_refresh_secret(initial_secret)) .await .unwrap() .is_none() ); assert!( - auth_tokens - .find_refresh_token(&hash_refresh_secret(new_secret)) + auth_sessions + .find_session_by_token_hash(&hash_refresh_secret(new_secret)) .await .unwrap() .is_none() @@ -2233,14 +2189,7 @@ client_id = "github-client-id" github_auth_mode(), ); let initial_secret = "refresh-secret-auth-context"; - state - .store_ref() - .refresh_tokens() - .await - .unwrap() - .insert_refresh_token(refresh_row(initial_secret)) - .await - .unwrap(); + open_cli_session(&state, initial_secret).await; let response = app .clone() @@ -2296,11 +2245,8 @@ client_id = "github-client-id" async fn concurrent_refresh_has_one_winner_and_revokes_chain() { let (app, state) = test_router(github_settings("https://fabro.example")); let initial_secret = "refresh-secret-concurrent"; - let auth_tokens = state.store_ref().refresh_tokens().await.unwrap(); - auth_tokens - .insert_refresh_token(refresh_row(initial_secret)) - .await - .unwrap(); + open_cli_session(&state, initial_secret).await; + let auth_sessions = &state.stores.auth_sessions; let barrier = Arc::new(Barrier::new(33)); let mut tasks = JoinSet::new(); @@ -2348,7 +2294,11 @@ client_id = "github-client-id" .map(str::to_string); } StatusCode::UNAUTHORIZED => { - assert_eq!(body["error"], "refresh_token_revoked"); + let error = body["error"].as_str().unwrap_or_default(); + assert!( + error == "refresh_token_revoked" || error == "refresh_token_expired", + "unexpected refresh error {error}" + ); revoked += 1; } other => panic!("unexpected refresh status {other}: {body}"), @@ -2359,15 +2309,15 @@ client_id = "github-client-id" assert_eq!(revoked, 31); let rotated_secret = rotated_secret.expect("one refresh should rotate the token"); assert!( - auth_tokens - .find_refresh_token(&hash_refresh_secret(initial_secret)) + auth_sessions + .find_session_by_token_hash(&hash_refresh_secret(initial_secret)) .await .unwrap() .is_none() ); assert!( - auth_tokens - .find_refresh_token(&hash_refresh_secret(&rotated_secret)) + auth_sessions + .find_session_by_token_hash(&hash_refresh_secret(&rotated_secret)) .await .unwrap() .is_none() @@ -2378,17 +2328,24 @@ client_id = "github-client-id" async fn logout_deletes_refresh_token_chain_and_returns_no_content() { let (app, state) = test_router(github_settings("https://fabro.example")); let secret = "refresh-secret-logout"; - let token = refresh_row(secret); - let chain_id = token.chain_id; - let auth_tokens = state.store_ref().refresh_tokens().await.unwrap(); - auth_tokens.insert_refresh_token(token).await.unwrap(); + let (session, token) = session_and_token(secret); + let auth_sessions = &state.stores.auth_sessions; + auth_sessions + .create_session(&session, &token) + .await + .unwrap(); - let sibling = RefreshToken { - token_hash: hash_refresh_secret("refresh-secret-logout-2"), - chain_id, - ..refresh_row("refresh-secret-logout-2") - }; - auth_tokens.insert_refresh_token(sibling).await.unwrap(); + let now = chrono::Utc::now(); + auth_sessions + .rotate( + &hash_refresh_secret(secret), + &hash_refresh_secret("refresh-secret-logout-2"), + now + chrono::Duration::days(30), + "fabro-test", + now, + ) + .await + .unwrap(); let response = app .oneshot( @@ -2407,15 +2364,15 @@ client_id = "github-client-id" assert_eq!(response.status(), StatusCode::NO_CONTENT); assert!( - auth_tokens - .find_refresh_token(&hash_refresh_secret(secret)) + auth_sessions + .find_session_by_token_hash(&hash_refresh_secret(secret)) .await .unwrap() .is_none() ); assert!( - auth_tokens - .find_refresh_token(&hash_refresh_secret("refresh-secret-logout-2")) + auth_sessions + .find_session_by_token_hash(&hash_refresh_secret("refresh-secret-logout-2")) .await .unwrap() .is_none() diff --git a/lib/apps/fabro-server/src/auth/mod.rs b/lib/apps/fabro-server/src/auth/mod.rs index 3b6e0c47d..da4823f5c 100644 --- a/lib/apps/fabro-server/src/auth/mod.rs +++ b/lib/apps/fabro-server/src/auth/mod.rs @@ -30,7 +30,8 @@ pub(crate) const REFRESH_TOKEN_PREFIX: &str = "fabro_refresh_"; pub(crate) use browser_shell::browser_shell; pub(crate) use cli_flow::web_routes; -pub(crate) use fabro_store::{AuthCode, ConsumeOutcome, RefreshToken}; +pub(crate) use fabro_store::AuthCode; +pub(crate) use fabro_store::auth_session_store::{AuthSessionRecord, RefreshToken, RotateOutcome}; pub use github_endpoints::GithubEndpoints; pub(crate) use jwt::{JwtError, JwtSubject, issue, verify}; pub(crate) use keys::{ diff --git a/lib/apps/fabro-server/src/serve.rs b/lib/apps/fabro-server/src/serve.rs index f46bb7e0a..4abe6dda1 100644 --- a/lib/apps/fabro-server/src/serve.rs +++ b/lib/apps/fabro-server/src/serve.rs @@ -780,7 +780,6 @@ where cache_path, )); let auth_code_store = store.auth_codes().await?; - let auth_token_store = store.refresh_tokens().await?; let (artifact_object_store, artifact_prefix) = build_artifact_object_store_with_server_secrets( &resolved_server_settings, &server_secrets, @@ -845,7 +844,7 @@ where spawn_auth_store_reapers( Arc::clone(&auth_code_store), - Arc::clone(&auth_token_store), + Arc::clone(&state.stores.auth_sessions), shutdown.clone(), ); @@ -1109,11 +1108,11 @@ async fn shutdown_signal() { fn spawn_auth_store_reapers( auth_codes: Arc, - auth_tokens: Arc, + auth_sessions: Arc, shutdown: CancellationToken, ) { spawn_auth_code_reaper(auth_codes, shutdown.clone()); - spawn_refresh_token_reaper(auth_tokens, shutdown); + spawn_refresh_token_reaper(auth_sessions, shutdown); } fn spawn_auth_code_reaper( @@ -1138,7 +1137,7 @@ fn spawn_auth_code_reaper( } fn spawn_refresh_token_reaper( - auth_tokens: Arc, + auth_sessions: Arc, shutdown: CancellationToken, ) { tokio::spawn(async move { @@ -1150,7 +1149,7 @@ fn spawn_refresh_token_reaper( () = shutdown.cancelled() => break, _ = interval.tick() => { let cutoff = chrono::Utc::now() - chrono::Duration::days(7); - if let Err(err) = auth_tokens.gc_expired(cutoff).await { + if let Err(err) = auth_sessions.gc_expired(cutoff).await { warn!(error = %err, "Failed to garbage collect expired refresh tokens"); } } diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 11a9a0948..9c60f6db6 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -85,8 +85,9 @@ use fabro_slack::threads::ThreadRegistry; use fabro_slack::{blocks as slack_blocks, connection as slack_connection}; use fabro_static::EnvVars; use fabro_store::{ - ArtifactKey, ArtifactStore, CachedRunProjection, Database, EventEnvelope, EventPayload, - NodeArtifact, PendingInterviewRecord, RunSummaryStore, StageArtifactEntry, StageId, + ArtifactKey, ArtifactStore, AuthSessionStore, CachedRunProjection, Database, EventEnvelope, + EventPayload, NodeArtifact, PendingInterviewRecord, RunSummaryStore, StageArtifactEntry, + StageId, }; #[cfg(test)] use fabro_types::BlockedReason; @@ -1153,6 +1154,7 @@ pub struct AppState { pub(crate) struct AppStores { pub(crate) runs: Arc, pub(crate) run_summaries: Arc, + pub(crate) auth_sessions: Arc, pub(crate) automations: Arc, pub(crate) environments: Arc, pub(crate) mcp_servers: Arc, @@ -1162,6 +1164,16 @@ pub(crate) struct AppStores { type PullRequestCreateLocks = Arc>>>>; +#[cfg(any(test, feature = "test-support"))] +impl AppState { + /// Access the auth session store so tests can seed CLI sessions against + /// the same SQLite pool the router reads from. + #[must_use] + pub fn test_auth_session_store(&self) -> &Arc { + &self.stores.auth_sessions + } +} + impl AppState { pub(crate) fn automation_store(&self) -> &AutomationStore { &self.stores.automations @@ -2434,6 +2446,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result store, - Err(err) => { - error!(error = %err, "Failed to open refresh token store while listing auth sessions"); - return ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to list auth sessions.", - ) - .into_response(); - } - }; - let cli_sessions = match auth_tokens + let cli_sessions = match state + .stores + .auth_sessions .active_cli_sessions(&authenticated.principal.identity, now) .await { - Ok(tokens) => tokens, + Ok(sessions) => sessions, Err(err) => { - error!(error = %err, "Failed to scan refresh tokens while listing auth sessions"); + error!(error = %err, "Failed to load auth sessions"); return ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "Failed to list auth sessions.", @@ -923,17 +914,17 @@ async fn list_auth_sessions( } }; - sessions.extend(cli_sessions.into_iter().map(|token| AuthSession { - id: format!("cli:{}", token.chain_id), + sessions.extend(cli_sessions.into_iter().map(|active| AuthSession { + id: format!("cli:{}", active.session.id), kind: "cli", current: false, provider: "github".to_string(), - login: token.login, + login: active.session.login, label: "Fabro CLI".to_string(), - user_agent: Some(token.user_agent), - created_at: token.issued_at, - last_seen_at: token.last_used_at, - expires_at: token.expires_at, + user_agent: Some(active.session.user_agent), + created_at: active.session.created_at, + last_seen_at: active.session.last_used_at, + expires_at: active.expires_at, revocable: true, })); sessions.sort_by(|left, right| { @@ -961,31 +952,26 @@ async fn delete_auth_session( .into_response(); } - let Some(raw_chain_id) = id.strip_prefix("cli:") else { + let Some(raw_session_id) = id.strip_prefix("cli:") else { return ApiError::not_found("Auth session not found.").into_response(); }; - let Ok(chain_id) = uuid::Uuid::parse_str(raw_chain_id) else { + let Ok(session_id) = uuid::Uuid::parse_str(raw_session_id) else { return ApiError::bad_request("Malformed CLI auth session id.").into_response(); }; - let auth_tokens = match state.store_ref().refresh_tokens().await { - Ok(store) => store, - Err(err) => { - error!(error = %err, "Failed to open refresh token store while deleting auth session"); - return ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to revoke auth session.", - ) - .into_response(); - } - }; - let deleted = match auth_tokens - .delete_active_chain_for_identity(&authenticated.principal.identity, chain_id, Utc::now()) + let deleted = match state + .stores + .auth_sessions + .delete_active_session_for_identity( + &authenticated.principal.identity, + session_id, + Utc::now(), + ) .await { Ok(deleted) => deleted, Err(err) => { - error!(error = %err, "Failed to scan refresh tokens while deleting auth session"); + error!(error = %err, "Failed to revoke auth session"); return ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, "Failed to revoke auth session.", diff --git a/lib/apps/fabro-server/tests/it/api/auth_sessions.rs b/lib/apps/fabro-server/tests/it/api/auth_sessions.rs index 0ba7d71d8..977c3c776 100644 --- a/lib/apps/fabro-server/tests/it/api/auth_sessions.rs +++ b/lib/apps/fabro-server/tests/it/api/auth_sessions.rs @@ -6,10 +6,11 @@ use axum::body::Body; use axum::http::{Request, StatusCode, header}; use cookie::{Cookie, CookieJar, Key}; use fabro_server::jwt_auth::resolve_auth_mode_with_lookup; -use fabro_server::server::{RouterOptions, build_router_with_options}; +use fabro_server::server::{AppState, RouterOptions, build_router_with_options}; use fabro_server::test_support::{TEST_SESSION_SECRET, TestAppStateBuilder}; use fabro_server::web_auth::{SESSION_COOKIE_NAME, SessionCookie}; -use fabro_store::{ArtifactStore, Database, RefreshToken}; +use fabro_store::auth_session_store::{AuthSessionRecord, RefreshToken}; +use fabro_store::{ArtifactStore, Database}; use hkdf::Hkdf; use object_store::memory::InMemory; use sha2::Sha256; @@ -18,7 +19,7 @@ use uuid::Uuid; use crate::helpers::{response_json, response_status, settings_from_toml}; -fn test_app(source: &str) -> (axum::Router, Arc) { +fn test_app(source: &str) -> (axum::Router, Arc) { let settings = settings_from_toml(source); let object_store: Arc = Arc::new(InMemory::new()); let store = Arc::new(Database::new( @@ -44,11 +45,11 @@ fn test_app(source: &str) -> (axum::Router, Arc) { TEST_SESSION_SECRET.to_string(), )])) .build(); - let app = build_router_with_options(state, &auth_mode, RouterOptions::default()); - (app, store) + let app = build_router_with_options(Arc::clone(&state), &auth_mode, RouterOptions::default()); + (app, state) } -fn github_app() -> (axum::Router, Arc) { +fn github_app() -> (axum::Router, Arc) { test_app( r#" _version = 1 @@ -118,24 +119,40 @@ fn session_cookie() -> String { .to_string() } -fn refresh_token(hash: [u8; 32], chain_id: Uuid) -> RefreshToken { +fn cli_session(id: Uuid, identity: fabro_types::IdpIdentity) -> AuthSessionRecord { let now = chrono::Utc::now(); - RefreshToken { - token_hash: hash, - chain_id, - identity: github_identity(), + AuthSessionRecord { + id, + identity, login: "octocat".to_string(), name: "The Octocat".to_string(), email: "octocat@example.com".to_string(), avatar_url: String::new(), + user_agent: "fabro-cli/it".to_string(), + created_at: now - chrono::Duration::days(1), + last_used_at: now, + } +} + +fn refresh_token(hash: [u8; 32], session_id: Uuid) -> RefreshToken { + let now = chrono::Utc::now(); + RefreshToken { + token_hash: hash, + session_id, issued_at: now - chrono::Duration::days(1), expires_at: now + chrono::Duration::days(30), - last_used_at: now, - used: false, - user_agent: "fabro-cli/it".to_string(), + used_at: None, } } +async fn seed_session(state: &AppState, session: AuthSessionRecord, token: RefreshToken) { + state + .test_auth_session_store() + .create_session(&session, &token) + .await + .expect("CLI session should insert"); +} + async fn get_sessions(app: axum::Router, cookie: &str) -> serde_json::Value { response_json( app.oneshot( @@ -174,16 +191,14 @@ async fn authenticated_browser_requests_receive_current_browser_session() { #[tokio::test] async fn active_cli_refresh_token_chains_for_identity_appear_in_unified_list() { - let (app, store) = github_app(); - let auth_tokens = store - .refresh_tokens() - .await - .expect("refresh token store should open"); + let (app, state) = github_app(); let chain_id = Uuid::new_v4(); - auth_tokens - .insert_refresh_token(refresh_token([1_u8; 32], chain_id)) - .await - .expect("refresh token should insert"); + seed_session( + &state, + cli_session(chain_id, github_identity()), + refresh_token([1_u8; 32], chain_id), + ) + .await; let body = get_sessions(app, &session_cookie()).await; let sessions = body["sessions"] @@ -207,26 +222,37 @@ async fn active_cli_refresh_token_chains_for_identity_appear_in_unified_list() { #[tokio::test] async fn inactive_and_other_identity_cli_tokens_are_excluded() { - let (app, store) = github_app(); - let auth_tokens = store - .refresh_tokens() - .await - .expect("refresh token store should open"); + let (app, state) = github_app(); let active_chain_id = Uuid::new_v4(); let now = chrono::Utc::now(); - let active = refresh_token([1_u8; 32], active_chain_id); - let mut expired = refresh_token([2_u8; 32], Uuid::new_v4()); - expired.expires_at = now - chrono::Duration::seconds(1); - let mut used = refresh_token([3_u8; 32], Uuid::new_v4()); - used.used = true; - let mut other = refresh_token([4_u8; 32], Uuid::new_v4()); - other.identity = other_identity(); - for token in [active, expired, used, other] { - auth_tokens - .insert_refresh_token(token) - .await - .expect("refresh token should insert"); + let expired_id = Uuid::new_v4(); + let mut expired = refresh_token([2_u8; 32], expired_id); + expired.expires_at = now - chrono::Duration::seconds(1); + + // A chain whose only token has already been rotated away has nothing left + // to spend, so it is inactive even though the token has not expired. + let used_id = Uuid::new_v4(); + let mut used = refresh_token([3_u8; 32], used_id); + used.used_at = Some(now); + + for (session, token) in [ + ( + cli_session(active_chain_id, github_identity()), + refresh_token([1_u8; 32], active_chain_id), + ), + (cli_session(expired_id, github_identity()), expired), + (cli_session(used_id, github_identity()), used), + ( + cli_session(Uuid::new_v4(), other_identity()), + refresh_token([4_u8; 32], Uuid::new_v4()), + ), + ] { + let token = RefreshToken { + session_id: session.id, + ..token + }; + seed_session(&state, session, token).await; } let body = get_sessions(app, &session_cookie()).await; @@ -250,23 +276,27 @@ async fn inactive_and_other_identity_cli_tokens_are_excluded() { #[tokio::test] async fn deleting_cli_session_removes_refresh_token_chain() { - let (app, store) = github_app(); - let auth_tokens = store - .refresh_tokens() - .await - .expect("refresh token store should open"); + let (app, state) = github_app(); let chain_id = Uuid::new_v4(); - let active = refresh_token([1_u8; 32], chain_id); - let mut used = refresh_token([2_u8; 32], chain_id); - used.used = true; - auth_tokens - .insert_refresh_token(active) + seed_session( + &state, + cli_session(chain_id, github_identity()), + refresh_token([1_u8; 32], chain_id), + ) + .await; + // Rotate once so the chain holds a spent token alongside its live one. + let now = chrono::Utc::now(); + state + .test_auth_session_store() + .rotate( + &[1_u8; 32], + &[2_u8; 32], + now + chrono::Duration::days(30), + "fabro-cli/it", + now, + ) .await - .expect("active refresh token should insert"); - auth_tokens - .insert_refresh_token(used) - .await - .expect("used refresh token should insert"); + .expect("rotation should succeed"); response_status( app.oneshot( @@ -285,15 +315,17 @@ async fn deleting_cli_session_removes_refresh_token_chain() { .await; assert!( - auth_tokens - .find_refresh_token(&[1_u8; 32]) + state + .test_auth_session_store() + .find_session_by_token_hash(&[1_u8; 32]) .await .expect("active token lookup should succeed") .is_none() ); assert!( - auth_tokens - .find_refresh_token(&[2_u8; 32]) + state + .test_auth_session_store() + .find_session_by_token_hash(&[2_u8; 32]) .await .expect("used token lookup should succeed") .is_none() diff --git a/lib/apps/fabro-server/tests/it/api/cli_auth_token.rs b/lib/apps/fabro-server/tests/it/api/cli_auth_token.rs index 2d0b743cb..b985f85b5 100644 --- a/lib/apps/fabro-server/tests/it/api/cli_auth_token.rs +++ b/lib/apps/fabro-server/tests/it/api/cli_auth_token.rs @@ -5,9 +5,10 @@ use axum::body::Body; use axum::http::{Request, StatusCode, header}; use base64::Engine; use fabro_server::jwt_auth::resolve_auth_mode_with_lookup; -use fabro_server::server::{RouterOptions, build_router_with_options}; +use fabro_server::server::{AppState, RouterOptions, build_router_with_options}; use fabro_server::test_support::test_app_state_with_store_and_runtime_settings; -use fabro_store::{ArtifactStore, AuthCode, Database, RefreshToken}; +use fabro_store::auth_session_store::{AuthSessionRecord, RefreshToken}; +use fabro_store::{ArtifactStore, AuthCode, Database}; use object_store::memory::InMemory; use sha2::{Digest, Sha256}; use tower::ServiceExt; @@ -15,7 +16,7 @@ use uuid::Uuid; use crate::helpers::{body_json, settings_from_toml}; -fn test_app(source: &str) -> (axum::Router, Arc) { +fn test_app(source: &str) -> (axum::Router, Arc, Arc) { let settings = settings_from_toml(source); let object_store: Arc = Arc::new(InMemory::new()); let store = Arc::new(Database::new( @@ -32,18 +33,15 @@ fn test_app(source: &str) -> (axum::Router, Arc) { _ => None, }) .expect("auth mode should resolve"); - let app = build_router_with_options( - test_app_state_with_store_and_runtime_settings( - settings.server_settings, - settings.manifest_run_defaults, - 5, - Arc::clone(&store), - artifact_store, - ), - &auth_mode, - RouterOptions::default(), + let state = test_app_state_with_store_and_runtime_settings( + settings.server_settings, + settings.manifest_run_defaults, + 5, + Arc::clone(&store), + artifact_store, ); - (app, store) + let app = build_router_with_options(Arc::clone(&state), &auth_mode, RouterOptions::default()); + (app, store, state) } fn pkce_challenge(verifier: &str) -> String { @@ -56,7 +54,7 @@ fn hash_refresh_secret(secret: &str) -> [u8; 32] { #[tokio::test] async fn cli_auth_token_exchanges_code_over_public_router() { - let (app, store) = test_app( + let (app, store, _state) = test_app( r#" _version = 1 @@ -124,7 +122,7 @@ client_id = "Iv1.test" #[tokio::test] async fn cli_auth_refresh_replay_revokes_chain_over_public_router() { - let (app, store) = test_app( + let (app, _store, state) = test_app( r#" _version = 1 @@ -141,22 +139,26 @@ url = "https://fabro.example" client_id = "Iv1.test" "#, ); - let auth_tokens = store.refresh_tokens().await.unwrap(); let now = chrono::Utc::now(); - auth_tokens - .insert_refresh_token(RefreshToken { - token_hash: hash_refresh_secret("integration-refresh"), - chain_id: Uuid::new_v4(), - identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(), - login: "octocat".to_string(), - name: "The Octocat".to_string(), - email: "octocat@example.com".to_string(), - avatar_url: String::new(), - issued_at: now, - expires_at: now + chrono::Duration::days(30), - last_used_at: now, - used: false, - user_agent: "fabro-cli/it".to_string(), + let session = AuthSessionRecord { + id: Uuid::new_v4(), + identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + avatar_url: String::new(), + user_agent: "fabro-cli/it".to_string(), + created_at: now, + last_used_at: now, + }; + state + .test_auth_session_store() + .create_session(&session, &RefreshToken { + token_hash: hash_refresh_secret("integration-refresh"), + session_id: session.id, + issued_at: now, + expires_at: now + chrono::Duration::days(30), + used_at: None, }) .await .unwrap();