use uuid::Uuid;
use vta_sdk::protocols::auth::{
AuthenticateResponse, Session as WireSession, TokenBundle, epoch_to_rfc3339,
};
use crate::auth::AuthError;
use crate::auth::backend::{
AuthAuditEvent, AuthBackend, RefreshInput, RefreshReuseReason, SessionStore,
};
use crate::auth::session::{
RefreshTombstone, Session, SessionState, now_epoch, refresh_token_hash,
};
pub async fn handle_refresh<B: AuthBackend>(
backend: &B,
input: RefreshInput,
) -> Result<AuthenticateResponse, B::Error> {
let claimed = backend
.sessions()
.take_session_id_by_refresh(&input.refresh_token)
.await
.map_err(|e| AuthError::Internal(format!("take_session_id_by_refresh failed: {e:?}")))?;
let Some(session_id) = claimed else {
return handle_unclaimed_refresh(backend, &input).await;
};
let old_session = backend
.sessions()
.get_session(&session_id)
.await
.map_err(|e| AuthError::Internal(format!("get_session failed: {e:?}")))?
.ok_or(AuthError::SessionNotFound)?;
if let Some(signer) = &input.signer_did
&& *signer != old_session.did
{
tracing::warn!(
session_id = %old_session.session_id,
session_did = %old_session.did,
signer = %signer,
"refresh rejected: signer DID does not match session DID",
);
return Err(AuthError::SignerMismatch.into());
}
if old_session.state != SessionState::Authenticated {
tracing::warn!(
session_id = %old_session.session_id,
did = %old_session.did,
"refresh rejected: session not authenticated",
);
return Err(AuthError::SessionStateMismatch.into());
}
let now = now_epoch();
if let Some(expires_at) = old_session.refresh_expires_at
&& now > expires_at
{
tracing::warn!(
session_id = %old_session.session_id,
did = %old_session.did,
"refresh rejected: refresh token expired",
);
return Err(AuthError::RefreshTokenExpired.into());
}
if let Some(idle_ttl) = backend.idle_timeout() {
let last_activity = if old_session.last_seen == 0 {
old_session.created_at
} else {
old_session.last_seen
};
if now.saturating_sub(last_activity) > idle_ttl {
tracing::warn!(
session_id = %old_session.session_id,
did = %old_session.did,
idle_for = now.saturating_sub(last_activity),
idle_ttl,
"refresh rejected: session idle past the timeout",
);
return Err(AuthError::SessionIdleTimeout.into());
}
}
let (amr, acr) = super::refresh_amr_acr(&old_session);
let role_resolution = backend.check_acl(&old_session.did).await?;
let new_session_id = old_session.session_id.clone();
let new_refresh_token = Uuid::new_v4().to_string();
let new_token_id = Uuid::new_v4().to_string();
let new_refresh_expires_at = now.saturating_add(backend.refresh_token_ttl());
let access_ttl = if acr == "aal2" {
backend.access_token_ttl_for_aal2()
} else {
backend.access_token_ttl()
};
let access_expires_at = now.saturating_add(access_ttl);
let access_token = backend
.mint_access_token(
&old_session.did,
&new_session_id,
&role_resolution.role,
&role_resolution.contexts,
&amr,
&acr,
old_session.tee_attested,
access_ttl,
&new_token_id,
)
.await?;
let new_session = Session {
session_id: new_session_id.clone(),
did: old_session.did.clone(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: now,
last_seen: old_session.last_seen,
refresh_token: Some(new_refresh_token.clone()),
refresh_expires_at: Some(new_refresh_expires_at),
tee_attested: old_session.tee_attested,
amr: amr.clone(),
acr: acr.clone(),
acr_expires_at: old_session.acr_expires_at,
token_id: Some(new_token_id.clone()),
session_pubkey_b58btc: old_session.session_pubkey_b58btc.clone(),
};
backend
.sessions()
.store_session(&new_session)
.await
.map_err(|e| AuthError::Internal(format!("store_session failed: {e:?}")))?;
backend
.sessions()
.store_refresh_index(&new_refresh_token, &new_session_id)
.await
.map_err(|e| AuthError::Internal(format!("store_refresh_index failed: {e:?}")))?;
if let Err(e) = backend
.sessions()
.store_refresh_tombstone(
&input.refresh_token,
&new_session_id,
&new_refresh_token,
now,
backend.refresh_token_ttl(),
)
.await
{
tracing::error!(
session_id = %new_session_id,
did = %old_session.did,
"failed to tombstone a rotated refresh token; a replay of it \
will be refused but not attributed: {e:?}",
);
}
backend.audit(AuthAuditEvent::Refreshed {
did: &old_session.did,
old_session_id: &old_session.session_id,
new_session_id: &new_session_id,
amr: &amr,
acr: &acr,
});
Ok(AuthenticateResponse {
session: WireSession {
id: new_session_id,
subject: old_session.did,
issued_at: epoch_to_rfc3339(now),
expires_at: epoch_to_rfc3339(access_expires_at),
amr,
acr,
},
tokens: TokenBundle {
access_token,
refresh_token: Some(new_refresh_token),
token_type: "Bearer".to_string(),
expires_in: access_ttl,
refresh_expires_in: Some(backend.refresh_token_ttl()),
scope: role_resolution
.contexts
.into_iter()
.map(|c| format!("ctx:{c}"))
.collect(),
},
})
}
async fn handle_unclaimed_refresh<B: AuthBackend>(
backend: &B,
input: &RefreshInput,
) -> Result<AuthenticateResponse, B::Error> {
let tombstone = backend
.sessions()
.get_refresh_tombstone(&input.refresh_token)
.await
.map_err(|e| AuthError::Internal(format!("get_refresh_tombstone failed: {e:?}")))?;
let Some(tombstone) = tombstone else {
return Err(AuthError::RefreshTokenInvalid.into());
};
let session = backend
.sessions()
.get_session(&tombstone.session_id)
.await
.map_err(|e| AuthError::Internal(format!("get_session failed: {e:?}")))?;
let now = now_epoch();
if let Some(session) = session.as_ref()
&& is_innocent_retry(session, &tombstone, now, backend.refresh_reuse_grace())
{
if let Some(signer) = &input.signer_did
&& *signer != session.did
{
return Err(AuthError::SignerMismatch.into());
}
tracing::info!(
session_id = %session.session_id,
did = %session.did,
age = now.saturating_sub(tombstone.rotated_at),
"refresh retried with the pre-rotation token inside the grace \
window — replaying the original rotation",
);
return replay_rotation(backend, session, now).await;
}
let reason = if session.is_none() {
RefreshReuseReason::SessionGone
} else if now.saturating_sub(tombstone.rotated_at) >= backend.refresh_reuse_grace() {
RefreshReuseReason::GraceExpired
} else {
RefreshReuseReason::ChainAdvanced
};
if session.is_some()
&& let Err(e) = backend
.sessions()
.delete_session(&tombstone.session_id)
.await
{
tracing::error!(
session_id = %tombstone.session_id,
"failed to revoke session after refresh-token reuse: {e:?}",
);
}
backend.audit(AuthAuditEvent::RefreshReuseDetected {
did: session.as_ref().map(|s| s.did.as_str()).unwrap_or(""),
session_id: &tombstone.session_id,
rotated_at: tombstone.rotated_at,
reason,
});
Err(AuthError::RefreshTokenInvalid.into())
}
fn is_innocent_retry(
session: &Session,
tombstone: &RefreshTombstone,
now: u64,
grace: u64,
) -> bool {
session.state == SessionState::Authenticated
&& now.saturating_sub(tombstone.rotated_at) < grace
&& session
.refresh_token
.as_deref()
.is_some_and(|live| refresh_token_hash(live) == tombstone.successor_hash)
}
async fn replay_rotation<B: AuthBackend>(
backend: &B,
session: &Session,
now: u64,
) -> Result<AuthenticateResponse, B::Error> {
let (amr, acr) = super::refresh_amr_acr(session);
let role_resolution = backend.check_acl(&session.did).await?;
let access_ttl = if acr == "aal2" {
backend.access_token_ttl_for_aal2()
} else {
backend.access_token_ttl()
};
let access_expires_at = now.saturating_add(access_ttl);
let (Some(refresh_token), Some(token_id)) =
(session.refresh_token.clone(), session.token_id.clone())
else {
return Err(AuthError::RefreshTokenInvalid.into());
};
let access_token = backend
.mint_access_token(
&session.did,
&session.session_id,
&role_resolution.role,
&role_resolution.contexts,
&amr,
&acr,
session.tee_attested,
access_ttl,
&token_id,
)
.await?;
backend.audit(AuthAuditEvent::Refreshed {
did: &session.did,
old_session_id: &session.session_id,
new_session_id: &session.session_id,
amr: &amr,
acr: &acr,
});
Ok(AuthenticateResponse {
session: WireSession {
id: session.session_id.clone(),
subject: session.did.clone(),
issued_at: epoch_to_rfc3339(now),
expires_at: epoch_to_rfc3339(access_expires_at),
amr,
acr,
},
tokens: TokenBundle {
access_token,
refresh_token: Some(refresh_token),
token_type: "Bearer".to_string(),
refresh_expires_in: session
.refresh_expires_at
.map(|expires| expires.saturating_sub(now)),
expires_in: access_ttl,
scope: role_resolution
.contexts
.into_iter()
.map(|c| format!("ctx:{c}"))
.collect(),
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::backend::{RoleResolution, SessionStore};
use crate::error::AppError;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct MemStore {
sessions: Mutex<HashMap<String, Session>>,
refresh_index: Mutex<HashMap<String, String>>,
tombstones: Mutex<HashMap<String, RefreshTombstone>>,
}
#[async_trait]
impl SessionStore for MemStore {
type Error = AppError;
async fn store_session(&self, s: &Session) -> Result<(), AppError> {
self.sessions
.lock()
.unwrap()
.insert(s.session_id.clone(), s.clone());
Ok(())
}
async fn get_session(&self, id: &str) -> Result<Option<Session>, AppError> {
Ok(self.sessions.lock().unwrap().get(id).cloned())
}
async fn delete_session(&self, id: &str) -> Result<(), AppError> {
if let Some(s) = self.sessions.lock().unwrap().remove(id)
&& let Some(t) = s.refresh_token
{
self.refresh_index.lock().unwrap().remove(&t);
}
Ok(())
}
async fn store_refresh_index(&self, token: &str, id: &str) -> Result<(), AppError> {
self.refresh_index
.lock()
.unwrap()
.insert(token.to_string(), id.to_string());
Ok(())
}
async fn take_session_id_by_refresh(
&self,
token: &str,
) -> Result<Option<String>, AppError> {
Ok(self.refresh_index.lock().unwrap().remove(token))
}
async fn count_pending_challenges(&self, _: &str) -> Result<usize, AppError> {
Ok(0)
}
async fn store_refresh_tombstone(
&self,
rotated: &str,
session_id: &str,
successor: &str,
rotated_at: u64,
ttl: u64,
) -> Result<(), AppError> {
self.tombstones.lock().unwrap().insert(
rotated.to_string(),
RefreshTombstone {
session_id: session_id.to_string(),
rotated_at,
expires_at: rotated_at + ttl,
successor_hash: refresh_token_hash(successor),
},
);
Ok(())
}
async fn get_refresh_tombstone(
&self,
token: &str,
) -> Result<Option<RefreshTombstone>, AppError> {
Ok(self.tombstones.lock().unwrap().get(token).cloned())
}
}
impl MemStore {
fn backdate_tombstone(&self, token: &str, secs: u64) {
let mut t = self.tombstones.lock().unwrap();
let entry = t.get_mut(token).expect("tombstone exists");
entry.rotated_at = entry.rotated_at.saturating_sub(secs);
}
fn session_count(&self) -> usize {
self.sessions.lock().unwrap().len()
}
}
struct MockBackend<S: SessionStore<Error = AppError>> {
store: S,
grace: u64,
alerts: Arc<Mutex<Vec<(String, RefreshReuseReason)>>>,
}
#[async_trait]
impl<S: SessionStore<Error = AppError>> AuthBackend for MockBackend<S> {
type Store = S;
type Error = AppError;
type Role = String;
fn sessions(&self) -> &S {
&self.store
}
#[allow(clippy::too_many_arguments)]
async fn mint_access_token(
&self,
_subject: &str,
_session_id: &str,
_role: &String,
_contexts: &[String],
_amr: &[String],
_acr: &str,
_tee_attested: bool,
_ttl_secs: u64,
jti: &str,
) -> Result<String, AppError> {
Ok(format!("access:{jti}"))
}
async fn check_acl(&self, _did: &str) -> Result<RoleResolution<String>, AppError> {
Ok(RoleResolution::new("reader".to_string()))
}
fn challenge_ttl(&self) -> u64 {
60
}
fn access_token_ttl(&self) -> u64 {
900
}
fn refresh_token_ttl(&self) -> u64 {
86_400
}
fn refresh_reuse_grace(&self) -> u64 {
self.grace
}
fn audit(&self, event: AuthAuditEvent<'_>) {
if let AuthAuditEvent::RefreshReuseDetected {
session_id, reason, ..
} = event
{
self.alerts
.lock()
.unwrap()
.push((session_id.to_string(), reason));
}
}
}
const DID: &str = "did:key:zHolder";
async fn seed<S: SessionStore<Error = AppError>>(store: &S) -> String {
let token = "refresh-0".to_string();
let now = now_epoch();
let session = Session {
session_id: DID.to_string(),
did: DID.to_string(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: now,
last_seen: now,
refresh_token: Some(token.clone()),
refresh_expires_at: Some(now + 86_400),
tee_attested: false,
amr: vec!["did".to_string()],
acr: "aal1".to_string(),
acr_expires_at: None,
token_id: Some("jti-0".to_string()),
session_pubkey_b58btc: None,
};
store.store_session(&session).await.unwrap();
store.store_refresh_index(&token, DID).await.unwrap();
token
}
async fn logged_in(grace: u64) -> (MockBackend<MemStore>, String) {
let b = MockBackend {
store: MemStore::default(),
grace,
alerts: Arc::new(Mutex::new(Vec::new())),
};
let token = seed(&b.store).await;
(b, token)
}
fn input(token: &str) -> RefreshInput {
RefreshInput {
refresh_token: token.to_string(),
signer_did: None,
}
}
fn alerts<S: SessionStore<Error = AppError>>(
b: &MockBackend<S>,
) -> Vec<(String, RefreshReuseReason)> {
b.alerts.lock().unwrap().clone()
}
fn assert_refused(err: &AppError) {
assert!(matches!(err, AppError::Authentication(_)), "got {err:?}");
}
#[tokio::test]
async fn refresh_rotates_the_token_and_spends_the_one_presented() {
let (b, first) = logged_in(30).await;
let resp = handle_refresh(&b, input(&first)).await.unwrap();
let second = resp.tokens.refresh_token.clone().expect("rotated token");
assert_ne!(second, first, "the presented token must not be re-issued");
assert!(
!b.store.refresh_index.lock().unwrap().contains_key(&first),
"the spent token must leave the live index",
);
assert!(
b.store.tombstones.lock().unwrap().contains_key(&first),
"the spent token must be tombstoned so a replay is attributable",
);
assert!(handle_refresh(&b, input(&second)).await.is_ok());
}
#[tokio::test]
async fn an_unrecognised_token_is_refused_without_raising_an_alert() {
let (b, _) = logged_in(30).await;
let err = handle_refresh(&b, input("never-issued")).await.unwrap_err();
assert_refused(&err);
assert!(alerts(&b).is_empty(), "a stranger's token is not an alert");
assert_eq!(b.store.session_count(), 1, "nothing revoked");
}
#[tokio::test]
async fn replaying_a_spent_token_after_the_grace_window_revokes_the_session() {
let (b, first) = logged_in(30).await;
handle_refresh(&b, input(&first)).await.unwrap();
b.store.backdate_tombstone(&first, 300);
let err = handle_refresh(&b, input(&first)).await.unwrap_err();
assert_refused(&err);
assert_eq!(
alerts(&b),
vec![(DID.to_string(), RefreshReuseReason::GraceExpired)],
);
assert_eq!(b.store.session_count(), 0, "session must be revoked");
}
#[tokio::test]
async fn detection_kills_every_token_descended_from_the_replayed_one() {
let (b, first) = logged_in(30).await;
let live = handle_refresh(&b, input(&first))
.await
.unwrap()
.tokens
.refresh_token
.unwrap();
b.store.backdate_tombstone(&first, 300);
handle_refresh(&b, input(&first)).await.unwrap_err();
let err = handle_refresh(&b, input(&live)).await.unwrap_err();
assert_refused(&err);
}
#[tokio::test]
async fn replaying_a_token_from_an_already_dead_session_raises_an_alert() {
let (b, first) = logged_in(30).await;
handle_refresh(&b, input(&first)).await.unwrap();
b.store.delete_session(DID).await.unwrap();
handle_refresh(&b, input(&first)).await.unwrap_err();
assert_eq!(
alerts(&b),
vec![(DID.to_string(), RefreshReuseReason::SessionGone)],
);
}
#[tokio::test]
async fn a_retry_inside_the_grace_window_replays_the_original_rotation() {
let (b, first) = logged_in(3600).await;
let lost = handle_refresh(&b, input(&first)).await.unwrap();
let retried = handle_refresh(&b, input(&first)).await.unwrap();
assert_eq!(
retried.tokens.refresh_token, lost.tokens.refresh_token,
"the retry must replay the same refresh token, not rotate again",
);
assert_eq!(
retried.tokens.access_token, lost.tokens.access_token,
"re-minted against the same jti, so the session pin still matches",
);
assert!(alerts(&b).is_empty(), "a lost response is not a compromise");
assert_eq!(b.store.session_count(), 1, "the client stays signed in");
}
#[tokio::test]
async fn a_retry_does_not_extend_the_refresh_deadline() {
let (b, first) = logged_in(3600).await;
handle_refresh(&b, input(&first)).await.unwrap();
let retried = handle_refresh(&b, input(&first)).await.unwrap();
let remaining = retried.tokens.refresh_expires_in.expect("a deadline");
assert!(
remaining <= b.refresh_token_ttl(),
"retry reported {remaining}s left, more than a full TTL",
);
}
#[tokio::test]
async fn a_replay_is_reuse_once_the_chain_has_advanced_even_inside_the_window() {
let (b, first) = logged_in(3600).await;
let second = handle_refresh(&b, input(&first))
.await
.unwrap()
.tokens
.refresh_token
.unwrap();
handle_refresh(&b, input(&second)).await.unwrap();
let err = handle_refresh(&b, input(&first)).await.unwrap_err();
assert_refused(&err);
assert_eq!(
alerts(&b),
vec![(DID.to_string(), RefreshReuseReason::ChainAdvanced)],
);
assert_eq!(b.store.session_count(), 0, "session must be revoked");
}
#[tokio::test]
async fn a_zero_grace_treats_an_immediate_retry_as_reuse() {
let (b, first) = logged_in(0).await;
handle_refresh(&b, input(&first)).await.unwrap();
handle_refresh(&b, input(&first)).await.unwrap_err();
assert_eq!(alerts(&b).len(), 1, "no concession at grace 0");
}
#[tokio::test]
async fn reuse_and_an_unknown_token_look_identical_to_the_caller() {
let (b, first) = logged_in(30).await;
handle_refresh(&b, input(&first)).await.unwrap();
b.store.backdate_tombstone(&first, 300);
let reused = handle_refresh(&b, input(&first)).await.unwrap_err();
let (b2, _) = logged_in(30).await;
let unknown = handle_refresh(&b2, input("never-issued"))
.await
.unwrap_err();
assert_eq!(format!("{reused}"), format!("{unknown}"));
}
#[tokio::test]
async fn a_backend_without_tombstone_support_degrades_to_rotation_only() {
#[derive(Default)]
struct NoTombstones(MemStore);
#[async_trait]
impl SessionStore for NoTombstones {
type Error = AppError;
async fn store_session(&self, s: &Session) -> Result<(), AppError> {
self.0.store_session(s).await
}
async fn get_session(&self, id: &str) -> Result<Option<Session>, AppError> {
self.0.get_session(id).await
}
async fn delete_session(&self, id: &str) -> Result<(), AppError> {
self.0.delete_session(id).await
}
async fn store_refresh_index(&self, t: &str, id: &str) -> Result<(), AppError> {
self.0.store_refresh_index(t, id).await
}
async fn take_session_id_by_refresh(
&self,
t: &str,
) -> Result<Option<String>, AppError> {
self.0.take_session_id_by_refresh(t).await
}
async fn count_pending_challenges(&self, d: &str) -> Result<usize, AppError> {
self.0.count_pending_challenges(d).await
}
}
let b = MockBackend {
store: NoTombstones::default(),
grace: 30,
alerts: Arc::new(Mutex::new(Vec::new())),
};
let first = seed(&b.store).await;
let second = handle_refresh(&b, input(&first))
.await
.unwrap()
.tokens
.refresh_token
.unwrap();
assert_ne!(second, first);
let err = handle_refresh(&b, input(&first)).await.unwrap_err();
assert_refused(&err);
assert!(alerts(&b).is_empty(), "no tombstone, no alert");
assert_eq!(b.store.0.session_count(), 1, "session survives");
}
#[tokio::test]
async fn a_failed_tombstone_write_does_not_fail_the_rotation() {
#[derive(Default)]
struct FailingTombstones(MemStore);
#[async_trait]
impl SessionStore for FailingTombstones {
type Error = AppError;
async fn store_session(&self, s: &Session) -> Result<(), AppError> {
self.0.store_session(s).await
}
async fn get_session(&self, id: &str) -> Result<Option<Session>, AppError> {
self.0.get_session(id).await
}
async fn delete_session(&self, id: &str) -> Result<(), AppError> {
self.0.delete_session(id).await
}
async fn store_refresh_index(&self, t: &str, id: &str) -> Result<(), AppError> {
self.0.store_refresh_index(t, id).await
}
async fn take_session_id_by_refresh(
&self,
t: &str,
) -> Result<Option<String>, AppError> {
self.0.take_session_id_by_refresh(t).await
}
async fn count_pending_challenges(&self, d: &str) -> Result<usize, AppError> {
self.0.count_pending_challenges(d).await
}
async fn store_refresh_tombstone(
&self,
_: &str,
_: &str,
_: &str,
_: u64,
_: u64,
) -> Result<(), AppError> {
Err(AppError::Internal("tombstone store unavailable".into()))
}
async fn get_refresh_tombstone(
&self,
t: &str,
) -> Result<Option<RefreshTombstone>, AppError> {
self.0.get_refresh_tombstone(t).await
}
}
let b = MockBackend {
store: FailingTombstones::default(),
grace: 30,
alerts: Arc::new(Mutex::new(Vec::new())),
};
let first = seed(&b.store).await;
let second = handle_refresh(&b, input(&first))
.await
.expect("the rotation is committed, so the refresh must succeed")
.tokens
.refresh_token
.unwrap();
handle_refresh(&b, input(&second))
.await
.expect("the rotated token must work");
assert!(alerts(&b).is_empty());
}
}