Serve CLI auth sessions from SQLite

Points the session listing, revocation, refresh, and logout paths at
`AuthSessionStore`. Listing a user's sessions and revoking one stop scanning
the whole refresh-token keyspace; both are now indexed queries.

Fixes two timestamps that were wrong by construction. `created_at` was fed
from the newest token's `issued_at`, so a session's reported start drifted
forward on every refresh, and `last_seen_at` read a field only ever set at
issue -- so both rendered the same value. They now come from the session row,
where they mean what they say.

Deletes `next_refresh_row`, which had to fabricate an identity of
("https://github.com", "0") and empty profile strings for the no-existing-row
case, because a token was required to carry chain-level fields. Rotation now
takes just the new hash, expiry, and user agent. That also removes the
pre-read it existed to feed, closing the window between that read and the
one `consume_and_rotate` did itself.

Opening the store per request is gone with it: five handlers each had a
500-response arm for "could not open the store", which field access on
AppStores cannot fail.

Drops the replay-revocation cache. Its only effect was reporting `revoked`
rather than `expired` for the third and later presentations in a concurrent
burst, and `fabro-client` (client.rs:508-513) matches both codes in one arm
and treats them identically. Replay detection itself is unaffected: it is
`Reused` into `delete_session`, which lives in the database. The concurrency
test now accepts either code, since losers that arrive after the winner's
revocation find the row already cascaded away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-07-26 00:00:15 -04:00
parent 701708b32d
commit be6dd7df97
No known key found for this signature in database
7 changed files with 307 additions and 316 deletions

View file

@ -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<chrono::Utc>,
) -> 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()

View file

@ -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::{

View file

@ -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<fabro_store::AuthCodeStore>,
auth_tokens: Arc<fabro_store::RefreshTokenStore>,
auth_sessions: Arc<fabro_store::AuthSessionStore>,
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<fabro_store::RefreshTokenStore>,
auth_sessions: Arc<fabro_store::AuthSessionStore>,
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");
}
}

View file

@ -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<Database>,
pub(crate) run_summaries: Arc<RunSummaryStore>,
pub(crate) auth_sessions: Arc<AuthSessionStore>,
pub(crate) automations: Arc<AutomationStore>,
pub(crate) environments: Arc<EnvironmentStore>,
pub(crate) mcp_servers: Arc<McpServerStore>,
@ -1162,6 +1164,16 @@ pub(crate) struct AppStores {
type PullRequestCreateLocks = Arc<Mutex<HashMap<RunId, Arc<AsyncMutex<()>>>>>;
#[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<AuthSessionStore> {
&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<Arc<AppS
);
let run_summaries =
store.attach_run_summary_store(Arc::new(RunSummaryStore::new(db_pool.clone())));
let auth_sessions = Arc::new(AuthSessionStore::new(db_pool.clone()));
let mcp_server_dir = mcp_server_dir_for_active_config(&active_config_path);
let mcp_server_pool = db_pool.clone();
let mcp_server_store = Arc::new(
@ -2540,6 +2553,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
stores: AppStores {
runs: store,
run_summaries,
auth_sessions,
automations: automation_store,
environments: environment_store,
mcp_servers: mcp_server_store,

View file

@ -897,24 +897,15 @@ async fn list_auth_sessions(
}
}
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 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.",

View file

@ -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<Database>) {
fn test_app(source: &str) -> (axum::Router, Arc<AppState>) {
let settings = settings_from_toml(source);
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let store = Arc::new(Database::new(
@ -44,11 +45,11 @@ fn test_app(source: &str) -> (axum::Router, Arc<Database>) {
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<Database>) {
fn github_app() -> (axum::Router, Arc<AppState>) {
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()

View file

@ -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<Database>) {
fn test_app(source: &str) -> (axum::Router, Arc<Database>, Arc<AppState>) {
let settings = settings_from_toml(source);
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let store = Arc::new(Database::new(
@ -32,18 +33,15 @@ fn test_app(source: &str) -> (axum::Router, Arc<Database>) {
_ => 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();