use async_trait::async_trait;
use serde::Serialize;
use std::fmt::Debug;
use crate::auth::session::Session;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AuthError {
#[error("forbidden")]
Forbidden,
#[error("did method rejected")]
DidMethodRejected,
#[error("too many pending challenges")]
PendingChallengeLimitReached,
#[error("session not found")]
SessionNotFound,
#[error("session replay or state mismatch")]
SessionStateMismatch,
#[error("challenge mismatch")]
ChallengeMismatch,
#[error("challenge expired")]
ChallengeExpired,
#[error("signer DID does not match session DID")]
SignerMismatch,
#[error("authenticate document names no recipient")]
MissingRecipient,
#[error("authenticate document is addressed to {recipient}, not to {own}")]
WrongRecipient { recipient: String, own: String },
#[error("message created_time outside freshness window")]
StaleMessage,
#[error("refresh token not found or consumed")]
RefreshTokenInvalid,
#[error("refresh token expired")]
RefreshTokenExpired,
#[error("session idle timeout exceeded")]
SessionIdleTimeout,
#[error("tee attestation failed: {0}")]
AttestationFailed(String),
#[error("internal: {0}")]
Internal(String),
}
#[async_trait]
pub trait SessionStore: Send + Sync + 'static {
type Error: Debug + Send + Sync + 'static;
async fn store_session(&self, session: &Session) -> Result<(), Self::Error>;
async fn get_session(&self, session_id: &str) -> Result<Option<Session>, Self::Error>;
async fn delete_session(&self, session_id: &str) -> Result<(), Self::Error>;
async fn take_session(&self, session_id: &str) -> Result<Option<Session>, Self::Error> {
let session = self.get_session(session_id).await?;
if session.is_some() {
self.delete_session(session_id).await?;
}
Ok(session)
}
async fn store_refresh_index(
&self,
refresh_token: &str,
session_id: &str,
) -> Result<(), Self::Error>;
async fn take_session_id_by_refresh(
&self,
refresh_token: &str,
) -> Result<Option<String>, Self::Error>;
async fn store_refresh_tombstone(
&self,
_rotated_token: &str,
_session_id: &str,
_successor_token: &str,
_rotated_at: u64,
_ttl: u64,
) -> Result<(), Self::Error> {
Ok(())
}
async fn get_refresh_tombstone(
&self,
_refresh_token: &str,
) -> Result<Option<crate::auth::session::RefreshTombstone>, Self::Error> {
Ok(None)
}
async fn count_pending_challenges(&self, did: &str) -> Result<usize, Self::Error>;
async fn touch_session(&self, session_id: &str, at: u64) -> Result<(), Self::Error> {
let Some(mut session) = self.get_session(session_id).await? else {
return Ok(());
};
if session.last_seen >= at {
return Ok(());
}
session.last_seen = at;
self.store_session(&session).await
}
}
#[async_trait]
pub trait AuthBackend: Send + Sync + 'static {
type Store: SessionStore;
type Error: From<AuthError> + Debug + Send + Sync + 'static;
type Role: std::fmt::Display + Serialize + Clone + Send + Sync + 'static;
fn sessions(&self) -> &Self::Store;
#[allow(clippy::too_many_arguments)]
async fn mint_access_token(
&self,
subject: &str,
session_id: &str,
role: &Self::Role,
contexts: &[String],
amr: &[String],
acr: &str,
tee_attested: bool,
ttl_secs: u64,
jti: &str,
) -> Result<String, Self::Error>;
async fn check_acl(&self, did: &str) -> Result<RoleResolution<Self::Role>, Self::Error>;
async fn has_effective_entry(&self, did: &str) -> bool {
self.check_acl(did).await.is_ok()
}
async fn validate_did(&self, _did: &str) -> Result<(), Self::Error> {
Ok(())
}
async fn attest_challenge(
&self,
_challenge_bytes: &[u8; 32],
) -> Result<AttestationOutcome, Self::Error> {
Ok(AttestationOutcome::not_attested())
}
fn max_pending_challenges_per_did(&self) -> usize {
10
}
fn audit(&self, event: AuthAuditEvent<'_>) {
match event {
AuthAuditEvent::ChallengeIssued { did, session_id } => {
tracing::info!(audit = true, %did, %session_id, "auth challenge issued");
}
AuthAuditEvent::Authenticated {
did, session_id, ..
} => {
tracing::info!(audit = true, %did, %session_id, "auth successful");
}
AuthAuditEvent::Refreshed {
did,
old_session_id,
new_session_id,
..
} => {
tracing::info!(
audit = true,
%did,
%old_session_id,
%new_session_id,
"token refreshed",
);
}
AuthAuditEvent::RefreshReuseDetected {
did,
session_id,
rotated_at,
reason,
} => {
tracing::error!(
audit = true,
security_alert = true,
%did,
%session_id,
rotated_at,
reason = reason.as_str(),
"refresh token reuse detected — session revoked",
);
}
}
}
fn challenge_ttl(&self) -> u64;
fn access_token_ttl(&self) -> u64;
fn access_token_ttl_for_aal2(&self) -> u64 {
let base = self.access_token_ttl();
std::cmp::max(60, base / 3)
}
fn refresh_token_ttl(&self) -> u64;
fn refresh_reuse_grace(&self) -> u64 {
30
}
fn didcomm_freshness_window(&self) -> u64 {
60
}
fn idle_timeout(&self) -> Option<u64> {
None
}
}
#[derive(Debug, Clone)]
pub struct RoleResolution<R> {
pub role: R,
pub contexts: Vec<String>,
}
impl<R> RoleResolution<R> {
pub fn new(role: R) -> Self {
Self {
role,
contexts: Vec::new(),
}
}
pub fn with_contexts(role: R, contexts: Vec<String>) -> Self {
Self { role, contexts }
}
}
#[derive(Debug, Clone)]
pub struct AttestationOutcome {
pub report: Option<serde_json::Value>,
pub attested: bool,
}
impl AttestationOutcome {
pub fn not_attested() -> Self {
Self {
report: None,
attested: false,
}
}
pub fn attested(report: serde_json::Value) -> Self {
Self {
report: Some(report),
attested: true,
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum AuthAuditEvent<'a> {
ChallengeIssued { did: &'a str, session_id: &'a str },
Authenticated {
did: &'a str,
session_id: &'a str,
amr: &'a [String],
acr: &'a str,
},
Refreshed {
did: &'a str,
old_session_id: &'a str,
new_session_id: &'a str,
amr: &'a [String],
acr: &'a str,
},
RefreshReuseDetected {
did: &'a str,
session_id: &'a str,
rotated_at: u64,
reason: RefreshReuseReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RefreshReuseReason {
GraceExpired,
ChainAdvanced,
SessionGone,
}
impl RefreshReuseReason {
pub fn as_str(self) -> &'static str {
match self {
Self::GraceExpired => "grace_expired",
Self::ChainAdvanced => "chain_advanced",
Self::SessionGone => "session_gone",
}
}
}
impl std::fmt::Display for RefreshReuseReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct ChallengeInput {
pub did: String,
pub session_pubkey_b58btc: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AudienceBinding {
Transport,
Recipient {
recipient: Option<String>,
own_did: Option<String>,
},
}
impl AudienceBinding {
pub fn check(&self) -> Result<(), AuthError> {
match self {
Self::Transport => Ok(()),
Self::Recipient { recipient, own_did } => {
let own = own_did.as_deref().ok_or_else(|| {
AuthError::Internal(
"this service has no DID configured, so a signed sign-in cannot be \
addressed to it"
.into(),
)
})?;
match recipient.as_deref() {
None => Err(AuthError::MissingRecipient),
Some(r) if r == own => Ok(()),
Some(r) => Err(AuthError::WrongRecipient {
recipient: r.to_string(),
own: own.to_string(),
}),
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct AuthenticateInput {
pub session_id: String,
pub challenge: String,
pub signer_did: String,
pub created_time: Option<u64>,
pub session_pubkey_b58btc: Option<String>,
pub audience: AudienceBinding,
}
#[derive(Debug, Clone)]
pub struct RefreshInput {
pub refresh_token: String,
pub signer_did: Option<String>,
}
#[cfg(test)]
mod audience_tests {
use super::{AudienceBinding, AuthError};
fn recipient(r: Option<&str>, own: Option<&str>) -> AudienceBinding {
AudienceBinding::Recipient {
recipient: r.map(String::from),
own_did: own.map(String::from),
}
}
#[test]
fn a_signed_document_must_be_addressed_to_this_service() {
assert!(
recipient(Some("did:key:zMe"), Some("did:key:zMe"))
.check()
.is_ok()
);
assert!(matches!(
recipient(Some("did:key:zOther"), Some("did:key:zMe")).check(),
Err(AuthError::WrongRecipient { .. })
));
assert!(matches!(
recipient(None, Some("did:key:zMe")).check(),
Err(AuthError::MissingRecipient)
));
assert!(
recipient(Some("did:key:zMe "), Some("did:key:zMe"))
.check()
.is_err()
);
assert!(matches!(
recipient(Some("did:key:zMe"), None).check(),
Err(AuthError::Internal(_))
));
}
#[test]
fn a_transport_binding_needs_no_recipient() {
assert!(AudienceBinding::Transport.check().is_ok());
}
}