use vta_sdk::protocols::auth::{
AuthenticateResponse, Session as WireSession, TokenBundle, epoch_to_rfc3339,
};
use crate::auth::AuthError;
use crate::auth::backend::{AuthBackend, AuthenticateInput, SessionStore};
use crate::auth::session::{Session, SessionState, now_epoch};
const DEFAULT_AMR: &[&str] = &["did"];
const DEFAULT_ACR: &str = "aal1";
pub async fn handle_authenticate<B: AuthBackend>(
backend: &B,
input: AuthenticateInput,
) -> Result<AuthenticateResponse, B::Error> {
let amr = DEFAULT_AMR.iter().map(|s| s.to_string()).collect();
handle_authenticate_with_aal(backend, input, amr, DEFAULT_ACR.into()).await
}
pub async fn handle_authenticate_with_aal<B: AuthBackend>(
backend: &B,
input: AuthenticateInput,
amr: Vec<String>,
acr: String,
) -> Result<AuthenticateResponse, B::Error> {
if let Err(e) = input.audience.check() {
tracing::warn!(
session_id = %input.session_id,
reason = %e,
"authenticate rejected: not addressed to this service",
);
return Err(e.into());
}
let session = backend
.sessions()
.get_session(&input.session_id)
.await
.map_err(|e| AuthError::Internal(format!("get_session failed: {e:?}")))?
.ok_or(AuthError::SessionNotFound)?;
if session.state != SessionState::ChallengeSent {
tracing::warn!(
session_id = %input.session_id,
did = %session.did,
"authenticate rejected: session not in ChallengeSent state (replay)",
);
return Err(AuthError::SessionStateMismatch.into());
}
if !super::constant_time_challenge_eq(&session.challenge, &input.challenge) {
tracing::warn!(
session_id = %input.session_id,
did = %session.did,
"authenticate rejected: challenge mismatch",
);
return Err(AuthError::ChallengeMismatch.into());
}
if session.did != input.signer_did {
tracing::warn!(
session_id = %input.session_id,
session_did = %session.did,
signer = %input.signer_did,
"authenticate rejected: signer DID mismatch",
);
return Err(AuthError::SignerMismatch.into());
}
let now = now_epoch();
if now.saturating_sub(session.created_at) > backend.challenge_ttl() {
tracing::warn!(
session_id = %input.session_id,
did = %session.did,
"authenticate rejected: challenge expired",
);
return Err(AuthError::ChallengeExpired.into());
}
super::check_freshness(
input.created_time,
session.created_at,
now,
backend.didcomm_freshness_window(),
)?;
if backend
.sessions()
.take_session(&input.session_id)
.await
.map_err(|e| AuthError::Internal(format!("take_session failed: {e:?}")))?
.is_none()
{
tracing::warn!(
session_id = %input.session_id,
did = %session.did,
"authenticate rejected: challenge already consumed (replay or race)",
);
return Err(AuthError::SessionStateMismatch.into());
}
let role_resolution = backend.check_acl(&session.did).await?;
let did = session.did.clone();
let minted = super::mint::mint_session_tokens(
backend,
&did,
&did,
&role_resolution.role,
&role_resolution.contexts,
&amr,
&acr,
session.tee_attested,
)
.await?;
let auth_session = Session {
session_id: did.clone(),
did: did.clone(),
challenge: String::new(),
state: SessionState::Authenticated,
created_at: now,
last_seen: now,
refresh_token: Some(minted.refresh_token.clone()),
refresh_expires_at: Some(minted.refresh_expires_at),
tee_attested: session.tee_attested,
amr: amr.clone(),
acr: acr.clone(),
acr_expires_at: None,
token_id: Some(minted.token_id.clone()),
session_pubkey_b58btc: input
.session_pubkey_b58btc
.or(session.session_pubkey_b58btc.clone()),
};
backend
.sessions()
.store_session(&auth_session)
.await
.map_err(|e| AuthError::Internal(format!("store_session failed: {e:?}")))?;
backend
.sessions()
.store_refresh_index(&minted.refresh_token, &did)
.await
.map_err(|e| AuthError::Internal(format!("store_refresh_index failed: {e:?}")))?;
Ok(AuthenticateResponse {
session: WireSession {
id: did.clone(),
subject: did,
issued_at: epoch_to_rfc3339(minted.issued_at),
expires_at: epoch_to_rfc3339(minted.access_expires_at),
amr,
acr,
},
tokens: TokenBundle {
access_token: minted.access_token,
refresh_token: Some(minted.refresh_token),
token_type: "Bearer".to_string(),
expires_in: minted.access_ttl,
refresh_expires_in: Some(backend.refresh_token_ttl()),
scope: role_resolution
.contexts
.into_iter()
.map(|c| format!("ctx:{c}"))
.collect(),
},
})
}
#[cfg(test)]
mod claim_tests {
use super::*;
use crate::auth::backend::{AudienceBinding, RoleResolution, SessionStore};
use crate::auth::session::now_epoch;
use crate::error::AppError;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
const DID: &str = "did:key:zHolder";
const CHALLENGE: &str = "challenge-0";
const SESSION_ID: &str = "11111111-1111-4111-8111-111111111111";
#[derive(Default)]
struct MemStore {
sessions: Mutex<HashMap<String, Session>>,
}
#[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, session_id: &str) -> Result<Option<Session>, AppError> {
Ok(self.sessions.lock().unwrap().get(session_id).cloned())
}
async fn delete_session(&self, session_id: &str) -> Result<(), AppError> {
self.sessions.lock().unwrap().remove(session_id);
Ok(())
}
async fn take_session(&self, session_id: &str) -> Result<Option<Session>, AppError> {
Ok(self.sessions.lock().unwrap().remove(session_id))
}
async fn store_refresh_index(&self, _: &str, _: &str) -> Result<(), AppError> {
Ok(())
}
async fn take_session_id_by_refresh(&self, _: &str) -> Result<Option<String>, AppError> {
Ok(None)
}
async fn count_pending_challenges(&self, did: &str) -> Result<usize, AppError> {
Ok(self
.sessions
.lock()
.unwrap()
.values()
.filter(|s| s.did == did && s.state == SessionState::ChallengeSent)
.count())
}
}
struct MockBackend {
store: MemStore,
}
#[async_trait]
impl AuthBackend for MockBackend {
type Store = MemStore;
type Error = AppError;
type Role = String;
fn sessions(&self) -> &MemStore {
&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> {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
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 challenge_row() -> Session {
let now = now_epoch();
Session {
session_id: SESSION_ID.to_string(),
did: DID.to_string(),
challenge: CHALLENGE.to_string(),
state: SessionState::ChallengeSent,
created_at: now,
last_seen: now,
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
amr: vec!["did".to_string()],
acr: "aal1".to_string(),
acr_expires_at: None,
token_id: None,
session_pubkey_b58btc: None,
}
}
fn input() -> AuthenticateInput {
AuthenticateInput {
session_id: SESSION_ID.to_string(),
challenge: CHALLENGE.to_string(),
signer_did: DID.to_string(),
created_time: None,
session_pubkey_b58btc: None,
audience: AudienceBinding::Transport,
}
}
#[tokio::test(flavor = "current_thread")]
async fn two_presentations_of_one_challenge_mint_once() {
let backend = MockBackend {
store: MemStore::default(),
};
backend.store.store_session(&challenge_row()).await.unwrap();
let (first, second) = tokio::join!(
handle_authenticate(&backend, input()),
handle_authenticate(&backend, input()),
);
let minted = [&first, &second].iter().filter(|r| r.is_ok()).count();
assert_eq!(
minted, 1,
"exactly one presentation may mint; got first={first:?} second={second:?}"
);
assert!(
backend
.store
.get_session(SESSION_ID)
.await
.unwrap()
.is_none(),
"the challenge row is gone either way"
);
}
#[tokio::test]
async fn the_default_take_session_reads_and_removes() {
#[derive(Default)]
struct Unoverridden(MemStore);
#[async_trait]
impl SessionStore for Unoverridden {
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, _: &str, _: &str) -> Result<(), AppError> {
Ok(())
}
async fn take_session_id_by_refresh(
&self,
_: &str,
) -> Result<Option<String>, AppError> {
Ok(None)
}
async fn count_pending_challenges(&self, did: &str) -> Result<usize, AppError> {
self.0.count_pending_challenges(did).await
}
}
let store = Unoverridden::default();
store.store_session(&challenge_row()).await.unwrap();
let taken = store.take_session(SESSION_ID).await.unwrap();
assert_eq!(taken.map(|s| s.did), Some(DID.to_string()));
assert!(store.get_session(SESSION_ID).await.unwrap().is_none());
assert!(store.take_session(SESSION_ID).await.unwrap().is_none());
}
}