mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
701708b32d
commit
be6dd7df97
7 changed files with 307 additions and 316 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue