use std::sync::Arc;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum_extra::TypedHeader;
use axum_extra::headers::Authorization;
use axum_extra::headers::authorization::Bearer;
use tracing::{debug, warn};
use crate::server::acl::Role;
use crate::server::auth::jwt::JwtKeys;
use crate::server::auth::session::{SessionState, get_session, now_epoch, touch_last_seen};
use crate::server::error::AppError;
use crate::server::store::KeyspaceHandle;
pub trait AuthState: Clone + Send + Sync + 'static {
fn jwt_keys(&self) -> Option<&Arc<JwtKeys>>;
fn sessions_ks(&self) -> &KeyspaceHandle;
fn has_step_up_passkey(&self, did: &str) -> impl std::future::Future<Output = bool> + Send;
}
#[derive(Debug, Clone)]
pub struct AuthClaims {
pub did: String,
pub role: Role,
pub session_id: String,
pub session_pubkey_b58btc: Option<String>,
pub amr: Vec<String>,
pub acr: String,
}
impl<S: AuthState> FromRequestParts<S> for AuthClaims {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let TypedHeader(auth) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
.await
.map_err(|_| {
warn!("auth rejected: missing or invalid Authorization header");
AppError::Unauthorized("missing or invalid Authorization header".into())
})?;
let token = auth.token();
let jwt_keys = state
.jwt_keys()
.ok_or_else(|| AppError::Unauthorized("auth not configured".into()))?;
let claims = jwt_keys.decode(token)?;
let session = get_session(state.sessions_ks(), &claims.session_id)
.await?
.ok_or_else(|| {
warn!(session_id = %claims.session_id, "auth rejected: session not found");
AppError::Unauthorized("session not found".into())
})?;
if session.state != SessionState::Authenticated {
warn!(session_id = %claims.session_id, "auth rejected: session not in authenticated state");
return Err(AppError::Unauthorized("session not authenticated".into()));
}
if let Some(ref session_token_id) = session.token_id {
if claims.jti.is_empty() {
warn!(session_id = %claims.session_id, "auth rejected: token has empty jti while session has token_id");
return Err(AppError::Unauthorized("token has been revoked".into()));
}
if claims.jti != *session_token_id {
warn!(session_id = %claims.session_id, "auth rejected: token revoked (stale jti)");
return Err(AppError::Unauthorized("token has been revoked".into()));
}
}
let role = claims.role.parse::<Role>()?;
let now = now_epoch();
let row_says_aal2 =
session.acr == "aal2" && session.acr_expires_at.is_none_or(|deadline| now < deadline);
let mut acr = if claims.acr == "aal2" && row_says_aal2 {
"aal2".to_string()
} else {
"aal1".to_string()
};
let amr = session.amr.clone();
if acr == "aal2"
&& !amr.iter().any(|m| m == "passkey")
&& state.has_step_up_passkey(&session.did).await
{
acr = "aal1".to_string();
}
if let Err(e) = touch_last_seen(state.sessions_ks(), &session, now).await {
warn!(
session_id = %claims.session_id,
error = %e,
"failed to record session activity; session may idle out early",
);
}
debug!(did = %claims.sub, role = %claims.role, session_id = %claims.session_id, "request authenticated");
Ok(AuthClaims {
did: claims.sub,
role,
session_id: claims.session_id,
session_pubkey_b58btc: session.session_pubkey_b58btc,
amr,
acr,
})
}
}
impl<S: AuthState> axum::extract::OptionalFromRequestParts<S> for AuthClaims {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Option<Self>, Self::Rejection> {
if !parts
.headers
.contains_key(axum::http::header::AUTHORIZATION)
{
return Ok(None);
}
<AuthClaims as FromRequestParts<S>>::from_request_parts(parts, state)
.await
.map(Some)
}
}
#[derive(Debug, Clone)]
pub struct ServiceAuth(pub AuthClaims);
impl<S: AuthState> FromRequestParts<S> for ServiceAuth {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let claims = AuthClaims::from_request_parts(parts, state).await?;
match claims.role {
Role::Service => Ok(ServiceAuth(claims)),
_ => {
warn!(did = %claims.did, role = %claims.role, "auth rejected: service role required");
Err(AppError::Forbidden("service role required".into()))
}
}
}
}
#[derive(Debug, Clone)]
pub struct AdminAuth(pub AuthClaims);
impl<S: AuthState> FromRequestParts<S> for AdminAuth {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let claims = AuthClaims::from_request_parts(parts, state).await?;
match claims.role {
Role::Admin => Ok(AdminAuth(claims)),
_ => {
warn!(did = %claims.did, role = %claims.role, "auth rejected: admin role required");
Err(AppError::Forbidden("admin role required".into()))
}
}
}
}
#[derive(Debug, Clone)]
pub struct StepUpAuth(pub AuthClaims);
impl<S: AuthState> FromRequestParts<S> for StepUpAuth {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let claims = AuthClaims::from_request_parts(parts, state).await?;
if claims.acr == "aal2" {
Ok(StepUpAuth(claims))
} else {
warn!(did = %claims.did, acr = %claims.acr, "auth rejected: step-up (aal2) required");
Err(AppError::StepUpRequired(
"operation requires a stepped-up (aal2) session".into(),
))
}
}
}
#[cfg(all(test, feature = "store-fjall"))]
mod tests {
use super::*;
use crate::server::auth::session::{Session, SessionState, store_session};
use crate::server::config::StoreConfig;
use crate::server::store::{KS_SESSIONS, Store};
use axum::http::Request;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Clone)]
struct TestState {
keys: Arc<JwtKeys>,
ks: KeyspaceHandle,
step_up_passkey_dids: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
}
impl TestState {
fn mark_step_up_passkey(&self, did: &str) {
self.step_up_passkey_dids
.lock()
.unwrap()
.insert(did.to_string());
}
}
impl AuthState for TestState {
fn jwt_keys(&self) -> Option<&Arc<JwtKeys>> {
Some(&self.keys)
}
fn sessions_ks(&self) -> &KeyspaceHandle {
&self.ks
}
fn has_step_up_passkey(&self, did: &str) -> impl std::future::Future<Output = bool> + Send {
let has = self.step_up_passkey_dids.lock().unwrap().contains(did);
async move { has }
}
}
async fn make_state() -> (TestState, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: PathBuf::from(dir.path()),
..StoreConfig::default()
})
.await
.unwrap();
let ks = store.keyspace(KS_SESSIONS).unwrap();
let keys = Arc::new(JwtKeys::from_ed25519_bytes(&[9u8; 32]).unwrap());
(
TestState {
keys,
ks,
step_up_passkey_dids: Arc::new(std::sync::Mutex::new(
std::collections::HashSet::new(),
)),
},
dir,
)
}
fn parts_with_bearer(token: &str) -> axum::http::request::Parts {
let req = Request::builder()
.header("authorization", format!("Bearer {token}"))
.body(())
.unwrap();
let (parts, _) = req.into_parts();
parts
}
fn parts_without_auth() -> axum::http::request::Parts {
let req = Request::builder().body(()).unwrap();
let (parts, _) = req.into_parts();
parts
}
async fn seed_session(state: &TestState, role: Role, jti: &str) -> String {
let session_id = uuid::Uuid::new_v4().to_string();
let _ = role; let session = Session {
session_id: session_id.clone(),
did: "did:example:caller".into(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: 0,
last_seen: 0,
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
token_id: Some(jti.to_string()),
session_pubkey_b58btc: None,
amr: Vec::new(),
acr: String::new(),
acr_expires_at: None,
not_after: None,
};
store_session(&state.ks, &session).await.unwrap();
session_id
}
fn issue(state: &TestState, session_id: &str, role: &str, jti: &str) -> String {
let mut claims = JwtKeys::new_claims(
"did:example:caller".into(),
session_id.into(),
role.into(),
60,
);
claims.jti = jti.into();
state.keys.encode(&claims).unwrap()
}
fn issue_with_aal(
state: &TestState,
session_id: &str,
role: &str,
jti: &str,
amr: Vec<String>,
acr: &str,
) -> String {
let mut claims = JwtKeys::new_claims(
"did:example:caller".into(),
session_id.into(),
role.into(),
60,
);
claims.jti = jti.into();
claims.amr = amr;
claims.acr = acr.into();
state.keys.encode(&claims).unwrap()
}
#[tokio::test]
async fn auth_claims_accepts_well_formed_token() {
let (state, _dir) = make_state().await;
let session_id = seed_session(&state, Role::Owner, "tok-1").await;
let token = issue(&state, &session_id, "owner", "tok-1");
let mut parts = parts_with_bearer(&token);
let auth = AuthClaims::from_request_parts(&mut parts, &state)
.await
.unwrap();
assert_eq!(auth.did, "did:example:caller");
assert_eq!(auth.role, Role::Owner);
}
#[tokio::test]
async fn auth_claims_rejects_missing_authorization_header() {
let (state, _dir) = make_state().await;
let mut parts = parts_without_auth();
let err = AuthClaims::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[tokio::test]
async fn auth_claims_rejects_stale_jti_after_rotation() {
let (state, _dir) = make_state().await;
let session_id = seed_session(&state, Role::Owner, "current-token-id").await;
let stale_token = issue(&state, &session_id, "owner", "previous-token-id");
let mut parts = parts_with_bearer(&stale_token);
let err = AuthClaims::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[tokio::test]
async fn auth_claims_rejects_unknown_session() {
let (state, _dir) = make_state().await;
let token = issue(&state, "ghost-session", "owner", "tok-1");
let mut parts = parts_with_bearer(&token);
let err = AuthClaims::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[tokio::test]
async fn auth_claims_rejects_session_in_challenge_state() {
let (state, _dir) = make_state().await;
let session_id = uuid::Uuid::new_v4().to_string();
let session = Session {
session_id: session_id.clone(),
did: "did:example:caller".into(),
challenge: "abc".into(),
state: SessionState::ChallengeSent,
created_at: 0,
last_seen: 0,
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
token_id: Some("tok".into()),
session_pubkey_b58btc: None,
amr: Vec::new(),
acr: String::new(),
acr_expires_at: None,
not_after: None,
};
store_session(&state.ks, &session).await.unwrap();
let token = issue(&state, &session_id, "owner", "tok");
let mut parts = parts_with_bearer(&token);
let err = AuthClaims::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[tokio::test]
async fn admin_auth_rejects_owner_role() {
let (state, _dir) = make_state().await;
let session_id = seed_session(&state, Role::Owner, "tok").await;
let token = issue(&state, &session_id, "owner", "tok");
let mut parts = parts_with_bearer(&token);
let err = AdminAuth::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)));
}
#[tokio::test]
async fn admin_auth_accepts_admin_role() {
let (state, _dir) = make_state().await;
let session_id = seed_session(&state, Role::Admin, "tok").await;
let token = issue(&state, &session_id, "admin", "tok");
let mut parts = parts_with_bearer(&token);
let admin = AdminAuth::from_request_parts(&mut parts, &state)
.await
.unwrap();
assert_eq!(admin.0.role, Role::Admin);
}
#[tokio::test]
async fn auth_claims_rejects_empty_jti_when_session_has_token_id() {
let (state, _dir) = make_state().await;
let session_id = seed_session(&state, Role::Owner, "expected-jti").await;
let token = issue(&state, &session_id, "owner", "");
let mut parts = parts_with_bearer(&token);
let err = AuthClaims::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[tokio::test]
async fn service_auth_accepts_service_role() {
let (state, _dir) = make_state().await;
let session_id = seed_session(&state, Role::Service, "tok").await;
let token = issue(&state, &session_id, "service", "tok");
let mut parts = parts_with_bearer(&token);
let svc = ServiceAuth::from_request_parts(&mut parts, &state)
.await
.unwrap();
assert_eq!(svc.0.role, Role::Service);
}
#[tokio::test]
async fn service_auth_rejects_owner_role() {
let (state, _dir) = make_state().await;
let session_id = seed_session(&state, Role::Owner, "tok").await;
let token = issue(&state, &session_id, "owner", "tok");
let mut parts = parts_with_bearer(&token);
let err = ServiceAuth::from_request_parts(&mut parts, &state)
.await
.unwrap_err();
assert!(matches!(err, AppError::Forbidden(_)));
}
#[tokio::test]
async fn a_stale_aal2_jwt_is_aal1_after_demotion() {
let (state, _dir) = make_state().await;
let session_id = uuid::Uuid::new_v4().to_string();
let session = Session {
session_id: session_id.clone(),
did: "did:example:caller".into(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: 0,
last_seen: 0,
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
token_id: Some("tok".into()),
session_pubkey_b58btc: None,
amr: vec!["did".to_string()],
acr: "aal2".into(),
acr_expires_at: None,
not_after: None,
};
store_session(&state.ks, &session).await.unwrap();
let stale_token = issue_with_aal(
&state,
&session_id,
"owner",
"tok",
vec!["did".to_string()],
"aal2",
);
let demoted = crate::server::auth::session::demote_non_passkey_step_up_sessions(
&state.ks,
"did:example:caller",
)
.await
.unwrap();
assert_eq!(demoted, 1);
let mut parts = parts_with_bearer(&stale_token);
let claims = AuthClaims::from_request_parts(&mut parts, &state)
.await
.unwrap();
assert_eq!(claims.acr, "aal1", "the demoted row must win over the JWT");
assert_eq!(claims.amr, vec!["did".to_string()]);
}
#[tokio::test]
async fn a_refreshed_aal2_row_without_passkey_amr_is_aal1_for_a_passkey_holder() {
let (state, _dir) = make_state().await;
state.mark_step_up_passkey("did:example:caller");
let session_id = uuid::Uuid::new_v4().to_string();
let session = Session {
session_id: session_id.clone(),
did: "did:example:caller".into(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: 0,
last_seen: 0,
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
token_id: Some("tok".into()),
session_pubkey_b58btc: None,
amr: vec!["did".to_string()],
acr: "aal2".into(),
acr_expires_at: None,
not_after: None,
};
store_session(&state.ks, &session).await.unwrap();
let token = issue_with_aal(
&state,
&session_id,
"owner",
"tok",
vec!["did".to_string()],
"aal2",
);
let mut parts = parts_with_bearer(&token);
let claims = AuthClaims::from_request_parts(&mut parts, &state)
.await
.unwrap();
assert_eq!(
claims.acr, "aal1",
"a passkey-holding subject's non-passkey aal2 must not survive, \
even when the JWT and row agree"
);
}
#[tokio::test]
async fn an_aal2_session_with_passkey_amr_is_left_at_aal2() {
let (state, _dir) = make_state().await;
state.mark_step_up_passkey("did:example:caller");
let session_id = uuid::Uuid::new_v4().to_string();
let session = Session {
session_id: session_id.clone(),
did: "did:example:caller".into(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: 0,
last_seen: 0,
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
token_id: Some("tok".into()),
session_pubkey_b58btc: None,
amr: vec!["did".to_string(), "passkey".to_string()],
acr: "aal2".into(),
acr_expires_at: None,
not_after: None,
};
store_session(&state.ks, &session).await.unwrap();
let token = issue_with_aal(
&state,
&session_id,
"owner",
"tok",
vec!["did".to_string(), "passkey".to_string()],
"aal2",
);
let mut parts = parts_with_bearer(&token);
let claims = AuthClaims::from_request_parts(&mut parts, &state)
.await
.unwrap();
assert_eq!(claims.acr, "aal2");
}
}