Skip to main content

vti_common/auth/
backend.rs

1//! Canonical auth-flow backend trait.
2//!
3//! Five callers (VTA, VTC, did-hosting-control, did-hosting-server,
4//! did-hosting-witness) all run the same shape of `/auth/challenge`,
5//! `/auth/authenticate`, `/auth/refresh` flow with minor policy
6//! differences (TEE attestation, DID-method allowlist, per-DID
7//! rate limit) and different storage / error / role types.
8//!
9//! [`AuthBackend`] is the boundary that lets the canonical handlers
10//! in [`crate::auth::handlers`] run unchanged across all five
11//! services. Each service implements `AuthBackend` once; the
12//! per-route boilerplate collapses to "build the input, call the
13//! handler, map the response."
14//!
15//! ## Trait shape
16//!
17//! - Associated [`Store`](AuthBackend::Store) — the session storage
18//!   primitives the handler needs. Implementors typically wrap their
19//!   keyspace handle in a thin adapter that implements
20//!   [`SessionStore`].
21//! - Associated [`Error`](AuthBackend::Error) — the backend's own
22//!   error type. Must convert from [`AuthError`] so the canonical
23//!   handler can raise the auth-specific failures and the route
24//!   layer surfaces them via its existing `IntoResponse` plumbing.
25//! - Associated [`Role`](AuthBackend::Role) — the backend's role
26//!   enum (vti-common's `Role`, did-hosting's `Role`, etc.). The
27//!   handler treats it opaquely; it appears in the JWT minter
28//!   contract and the audit log only.
29//! - Default-method policy hooks — [`validate_did`], [`attest_challenge`],
30//!   [`max_pending_challenges_per_did`], [`audit`] — backends override
31//!   only when they need non-default behaviour. Most backends override
32//!   one or two; the rest inherit safe defaults.
33//!
34//! ## What stays out of the trait
35//!
36//! - Transport (REST vs. DIDComm) — the canonical handler takes a
37//!   pre-extracted [`AuthInput`] struct; transport-specific unpacking
38//!   stays in the route handler.
39//! - JWT structure — `JwtKeys` is the same across all callers; the
40//!   handler holds a `&JwtKeys` reference and mints directly.
41//! - Wire-shape serialisation — canonical request / response types
42//!   live in `vta_sdk::protocols::auth` and are shared with clients.
43
44use async_trait::async_trait;
45use serde::Serialize;
46use std::fmt::Debug;
47
48use crate::auth::session::Session;
49
50// ---------------------------------------------------------------------------
51// Canonical auth-flow errors
52// ---------------------------------------------------------------------------
53
54/// Auth-specific failures the canonical handlers can raise.
55///
56/// Each backend's `Error` associated type must implement
57/// `From<AuthError>` so the handler can return these variants and
58/// the route layer surfaces them via its existing `IntoResponse`
59/// plumbing (e.g. vti-common's `AppError::Unauthorized(_)` arm).
60///
61/// `#[non_exhaustive]`: this enum has grown with every auth feature, and
62/// each addition was a silent breaking change for consumers matching it
63/// exhaustively. Marking it costs downstream a wildcard arm once and
64/// makes every future failure mode additive.
65#[derive(Debug, thiserror::Error)]
66#[non_exhaustive]
67pub enum AuthError {
68    /// DID is not in the backend's ACL, or the ACL entry is expired.
69    /// Returned as 403 Forbidden to avoid revealing whether the DID
70    /// exists in the ACL system at all (timing-side-channel
71    /// mitigation; the ACL check happens before any other gate).
72    #[error("forbidden")]
73    Forbidden,
74
75    /// The DID's method (e.g. `did:foo:...`) is not in the backend's
76    /// allowlist. Distinct from `Forbidden` so audit logs can
77    /// distinguish "wrong method" from "not in ACL". Surfaced to
78    /// callers as a generic 403 to avoid leaking the allowlist
79    /// contents.
80    #[error("did method rejected")]
81    DidMethodRejected,
82
83    /// Too many concurrent `ChallengeSent` sessions for this DID;
84    /// the per-DID rate limit (default 10) is exhausted. Returned
85    /// as 429 Too Many Requests so clients can back off.
86    #[error("too many pending challenges")]
87    PendingChallengeLimitReached,
88
89    /// The session referenced by the request does not exist or has
90    /// expired (TTL swept it). Returned as 401 Unauthorized; the
91    /// holder must restart the challenge flow.
92    #[error("session not found")]
93    SessionNotFound,
94
95    /// The session exists but was already authenticated (replay) or
96    /// is otherwise not in the state the request expected. Returned
97    /// as 401 Unauthorized; the holder must restart.
98    #[error("session replay or state mismatch")]
99    SessionStateMismatch,
100
101    /// The presented challenge does not match what was issued for
102    /// this session. Constant-time compared. Returned as 401
103    /// Unauthorized.
104    #[error("challenge mismatch")]
105    ChallengeMismatch,
106
107    /// The challenge is older than the backend's configured TTL.
108    /// Returned as 401 Unauthorized; the holder must request a
109    /// fresh challenge.
110    #[error("challenge expired")]
111    ChallengeExpired,
112
113    /// The signer DID extracted from the transport (DIDComm `from`,
114    /// SIOPv2 `iss`) does not match the DID the session was issued
115    /// to. Critical binding — without this check, any leaked
116    /// challenge could be redeemed by any signer. Returned as 401
117    /// Unauthorized.
118    #[error("signer DID does not match session DID")]
119    SignerMismatch,
120
121    /// The DIDComm envelope's `created_time` is outside the freshness
122    /// window. Replay defense for the DIDComm transport. Returned as
123    /// 401 Unauthorized.
124    #[error("message created_time outside freshness window")]
125    StaleMessage,
126
127    /// The refresh token was not found or already consumed. Atomic
128    /// claim semantics: at most one caller succeeds per token.
129    /// Returned as 401 Unauthorized; the holder must re-authenticate.
130    #[error("refresh token not found or consumed")]
131    RefreshTokenInvalid,
132
133    /// The refresh token's absolute expiry has passed. Returned as
134    /// 401 Unauthorized; the holder must re-authenticate.
135    #[error("refresh token expired")]
136    RefreshTokenExpired,
137
138    /// The session went longer than [`AuthBackend::idle_timeout`]
139    /// without user activity, so it may no longer be renewed.
140    ///
141    /// Distinct from [`Self::RefreshTokenExpired`] because the two mean
142    /// different things to an operator and have different fixes: a
143    /// refresh token that ran out is a session that lived its full
144    /// span, while this is one abandoned mid-life. A console that
145    /// collapsed them would tell someone who stepped away for lunch
146    /// that their session had reached its maximum age.
147    #[error("session idle timeout exceeded")]
148    SessionIdleTimeout,
149
150    /// TEE attestation failed in a `TeeMode::Required` deployment.
151    /// Returned as 503 Service Unavailable (the operator's TEE is
152    /// broken; the caller did nothing wrong).
153    #[error("tee attestation failed: {0}")]
154    AttestationFailed(String),
155
156    /// Surface for any wrapped error from the backend's policy or
157    /// storage layer that doesn't fit the variants above. The
158    /// canonical handler does not introspect this; it surfaces
159    /// unchanged via the backend's `Error::from(AuthError::Internal)`.
160    #[error("internal: {0}")]
161    Internal(String),
162}
163
164// ---------------------------------------------------------------------------
165// SessionStore — the storage primitives the canonical handlers need
166// ---------------------------------------------------------------------------
167
168/// Storage operations the canonical handlers invoke.
169///
170/// Each backend wraps its own keyspace handle (vti-common's
171/// `KeyspaceHandle` enum, did-hosting's `KeyspaceHandle` struct,
172/// future cloud-store backends) in an adapter implementing this
173/// trait. The handler holds a `&S` and never touches the concrete
174/// storage type directly.
175///
176/// ## Why this trait, not a single `KeyspaceHandle` type
177///
178/// did-hosting and vti-common evolved separate keyspace
179/// abstractions before the auth-architecture consolidation. Merging
180/// them is out of scope for the auth work; the trait boundary
181/// keeps them independent while still sharing the auth-flow code.
182#[async_trait]
183pub trait SessionStore: Send + Sync + 'static {
184    /// Wrapped error type. Conversion to `AuthError::Internal` is
185    /// the handler's responsibility (via `?` and the backend's
186    /// `From<AuthError>` impl).
187    type Error: Debug + Send + Sync + 'static;
188
189    /// Persist a session under its `session_id`.
190    async fn store_session(&self, session: &Session) -> Result<(), Self::Error>;
191
192    /// Load a session by `session_id`. `Ok(None)` if missing or expired-and-swept.
193    async fn get_session(&self, session_id: &str) -> Result<Option<Session>, Self::Error>;
194
195    /// Delete a session and its refresh-token reverse-index.
196    async fn delete_session(&self, session_id: &str) -> Result<(), Self::Error>;
197
198    /// Persist the `refresh_token → session_id` reverse-index.
199    /// Implementors choose whether to hash the key (recommended;
200    /// vti-common does, did-hosting historically does not) — the
201    /// handler treats the token as an opaque bearer.
202    async fn store_refresh_index(
203        &self,
204        refresh_token: &str,
205        session_id: &str,
206    ) -> Result<(), Self::Error>;
207
208    /// Atomically claim-and-delete the `refresh_token → session_id`
209    /// reverse-index. Cross-replica safe (Redis GETDEL / DynamoDB
210    /// DeleteItem ReturnValues=ALL_OLD / fjall mutex). Exactly one
211    /// concurrent caller observes `Some` for any given token.
212    /// Used by `/auth/refresh` to close the rotation TOCTOU.
213    async fn take_session_id_by_refresh(
214        &self,
215        refresh_token: &str,
216    ) -> Result<Option<String>, Self::Error>;
217
218    /// Count `ChallengeSent` sessions for `did`. Backends with an
219    /// O(1) per-DID tracker (did-hosting) override the default
220    /// O(N) prefix-scan implementation by re-implementing this
221    /// method.
222    ///
223    /// Default implementation provided for backends that haven't
224    /// yet built a tracker — correct but slow under load. Override
225    /// before relying on per-DID rate limiting in production.
226    async fn count_pending_challenges(&self, did: &str) -> Result<usize, Self::Error>;
227
228    /// Record that `session_id` saw **user activity** at `at` (epoch
229    /// seconds), by writing [`Session::last_seen`].
230    ///
231    /// This is what [`AuthBackend::idle_timeout`] measures against, so
232    /// only genuine interaction may call it. An admin console that
233    /// polls a status endpoint on a timer would, if that poll counted,
234    /// hold a session open for as long as the tab stayed open — which
235    /// is precisely the thing an idle timeout exists to prevent.
236    ///
237    /// The default is a read-modify-write, which is **not** atomic
238    /// against a concurrent session mutation: a racing write can lose
239    /// the `last_seen` bump. That is the safe direction to lose in — a
240    /// dropped bump expires a session early, never late — and it
241    /// follows the [`Self::count_pending_challenges`] precedent of a
242    /// correct-but-unoptimised default. A backend with a
243    /// compare-and-set or partial-update primitive should override.
244    ///
245    /// A missing session is `Ok(())`, not an error: the row may have
246    /// been swept or revoked between authentication and this call, and
247    /// there is nothing to record.
248    async fn touch_session(&self, session_id: &str, at: u64) -> Result<(), Self::Error> {
249        let Some(mut session) = self.get_session(session_id).await? else {
250            return Ok(());
251        };
252        if session.last_seen >= at {
253            return Ok(());
254        }
255        session.last_seen = at;
256        self.store_session(&session).await
257    }
258}
259
260// ---------------------------------------------------------------------------
261// AuthBackend — per-service policy + glue
262// ---------------------------------------------------------------------------
263
264/// Pluggable backend for the canonical `/auth/*` handlers.
265///
266/// One implementation per service. Most methods have safe defaults;
267/// implementors override only the policy hooks their service
268/// actually exercises (TEE attestation, DID-method allowlist, etc.).
269#[async_trait]
270pub trait AuthBackend: Send + Sync + 'static {
271    /// Session storage adapter.
272    type Store: SessionStore;
273
274    /// Backend-local error type. Must convert from [`AuthError`]
275    /// so the canonical handler can raise auth-specific failures.
276    /// Must implement `IntoResponse` at the route boundary; the
277    /// trait does not bound that here (would force an axum
278    /// dependency on every backend), but the canonical handler
279    /// surfaces the error verbatim and the route layer renders
280    /// it via its existing path.
281    type Error: From<AuthError> + Debug + Send + Sync + 'static;
282
283    /// Backend's role type. The handler holds it opaquely between
284    /// ACL lookup and JWT minting.
285    ///
286    /// - `Display` so the handler can render it into the JWT
287    ///   `role` claim (which is a plain string per the canonical
288    ///   spec).
289    /// - `Serialize` so the audit hook can include it in
290    ///   structured logs.
291    type Role: std::fmt::Display + Serialize + Clone + Send + Sync + 'static;
292
293    // -------- Plumbing --------
294
295    /// Session store handle. The handler invokes the
296    /// [`SessionStore`] methods through this.
297    fn sessions(&self) -> &Self::Store;
298
299    /// Mint an access token JWT for an authenticated session.
300    ///
301    /// The trait abstracts over the concrete JWT minter — VTA + VTC
302    /// use `vti_common::auth::jwt::JwtKeys`; did-hosting has its own
303    /// minter type with the same shape but a separate `AppError`
304    /// surface. Each backend implements this method using whatever
305    /// minter it holds; the canonical handler treats the return
306    /// value as an opaque base64url-encoded JWS.
307    #[allow(clippy::too_many_arguments)]
308    async fn mint_access_token(
309        &self,
310        subject: &str,
311        session_id: &str,
312        role: &Self::Role,
313        contexts: &[String],
314        amr: &[String],
315        acr: &str,
316        tee_attested: bool,
317        ttl_secs: u64,
318        // Per-issue `jti` nonce; embedded in the token and pinned to the
319        // session's `token_id` so a freshly-minted token supersedes the prior.
320        jti: &str,
321    ) -> Result<String, Self::Error>;
322
323    // -------- Policy hooks --------
324
325    /// Resolve a DID to a role + context scope. Returning an error
326    /// (typically [`AuthError::Forbidden`]) rejects the request
327    /// before any other gate fires.
328    async fn check_acl(&self, did: &str) -> Result<RoleResolution<Self::Role>, Self::Error>;
329
330    /// Whether `did` currently has an effective ACL entry.
331    ///
332    /// Used by [`crate::auth::handlers::handle_challenge`], which needs to
333    /// know whether to persist a session but **must not disclose the answer
334    /// to the caller**. Returning `bool` rather than `Result` is deliberate:
335    /// the caller cannot act on the difference between "no entry" and "the
336    /// ACL store failed", and both have to produce the same response, so a
337    /// failure reads as absent and the request gets an unusable challenge.
338    /// The real error surfaces at `/auth/authenticate`, where the caller has
339    /// already proven control of the DID.
340    ///
341    /// Default: `check_acl(did).await.is_ok()`.
342    async fn has_effective_entry(&self, did: &str) -> bool {
343        self.check_acl(did).await.is_ok()
344    }
345
346    /// Optional DID-method validation gate. Default: accept any
347    /// method (backends with no allowlist). VTA overrides in TEE
348    /// mode to enforce `allowed_did_methods`.
349    async fn validate_did(&self, _did: &str) -> Result<(), Self::Error> {
350        Ok(())
351    }
352
353    /// Optional TEE attestation hook. Returns the attestation
354    /// report (if produced) and whether attestation succeeded.
355    /// Default: no attestation (None, false). VTA overrides in
356    /// TEE mode; in `TeeMode::Required` a failure here must be
357    /// raised as [`AuthError::AttestationFailed`].
358    async fn attest_challenge(
359        &self,
360        _challenge_bytes: &[u8; 32],
361    ) -> Result<AttestationOutcome, Self::Error> {
362        Ok(AttestationOutcome::not_attested())
363    }
364
365    /// Cap on concurrent `ChallengeSent` sessions per DID. Default
366    /// 10. Setting to 0 disables per-DID rate limiting (still
367    /// IP-rate-limited at the tower-governor layer). Backends with
368    /// low-trust callers may want this higher; backends with
369    /// admin-only callers can keep it at 10.
370    fn max_pending_challenges_per_did(&self) -> usize {
371        10
372    }
373
374    /// Audit hook fired at the end of each handler. Default impl
375    /// emits via `tracing::info!(audit=true)`; backends with
376    /// structured audit pipelines (e.g. VTC's audit log with HMAC
377    /// actor hashing) can override.
378    fn audit(&self, event: AuthAuditEvent<'_>) {
379        match event {
380            AuthAuditEvent::ChallengeIssued { did, session_id } => {
381                tracing::info!(audit = true, %did, %session_id, "auth challenge issued");
382            }
383            AuthAuditEvent::Authenticated {
384                did, session_id, ..
385            } => {
386                tracing::info!(audit = true, %did, %session_id, "auth successful");
387            }
388            AuthAuditEvent::Refreshed {
389                did,
390                old_session_id,
391                new_session_id,
392                ..
393            } => {
394                tracing::info!(
395                    audit = true,
396                    %did,
397                    %old_session_id,
398                    %new_session_id,
399                    "token refreshed",
400                );
401            }
402        }
403    }
404
405    // -------- Timings --------
406
407    /// Challenge TTL in seconds. Typical: 60.
408    fn challenge_ttl(&self) -> u64;
409
410    /// Access-token TTL in seconds. Typical: 900 (15 min).
411    fn access_token_ttl(&self) -> u64;
412
413    /// Access-token TTL in seconds for a stepped-up
414    /// (`acr=aal2`) session. Default: 1/3 of [`Self::access_token_ttl`]
415    /// floored to a minimum of 60 seconds — closes M2 from the
416    /// May 2026 security review, which observed that a leaked
417    /// `aal2` token has the same 15-minute window as a `aal1`
418    /// token despite the elevated privileges it grants.
419    ///
420    /// Backends can override to set their own ratio or to
421    /// disable the elevation (return `access_token_ttl()` for a
422    /// uniform TTL).
423    fn access_token_ttl_for_aal2(&self) -> u64 {
424        let base = self.access_token_ttl();
425        std::cmp::max(60, base / 3)
426    }
427
428    /// Refresh-token TTL in seconds. Typical: 86400 (24 h).
429    fn refresh_token_ttl(&self) -> u64;
430
431    /// DIDComm `created_time` freshness window in seconds. The
432    /// canonical handler rejects messages older than this against
433    /// `session.created_at` to bound replay risk. Default 60s.
434    fn didcomm_freshness_window(&self) -> u64 {
435        60
436    }
437
438    /// Idle timeout in seconds: how long a session may go without
439    /// **user activity** before it may no longer be renewed.
440    ///
441    /// `None` (the default) means no idle enforcement — a session is
442    /// bounded only by its refresh-token expiry, which is the
443    /// behaviour every backend had before this existed.
444    ///
445    /// This is deliberately distinct from [`Self::access_token_ttl`].
446    /// An access token is short and rotates; the idle timeout is a
447    /// policy about the operator, and a browser session that renews on
448    /// a timer would otherwise never lapse no matter how long the
449    /// human had been away. `handle_refresh` measures it against
450    /// [`SessionStore::touch_session`]'s `last_seen`.
451    fn idle_timeout(&self) -> Option<u64> {
452        None
453    }
454}
455
456// ---------------------------------------------------------------------------
457// Supporting types
458// ---------------------------------------------------------------------------
459
460/// Result of an ACL lookup. The handler propagates this opaquely
461/// into the JWT minter and audit event.
462#[derive(Debug, Clone)]
463pub struct RoleResolution<R> {
464    pub role: R,
465    /// Context-scoped backends (VTA) populate this. Backends with
466    /// flat ACL (did-hosting) leave it empty.
467    pub contexts: Vec<String>,
468}
469
470impl<R> RoleResolution<R> {
471    pub fn new(role: R) -> Self {
472        Self {
473            role,
474            contexts: Vec::new(),
475        }
476    }
477
478    pub fn with_contexts(role: R, contexts: Vec<String>) -> Self {
479        Self { role, contexts }
480    }
481}
482
483/// Outcome of the optional TEE attestation hook.
484#[derive(Debug, Clone)]
485pub struct AttestationOutcome {
486    /// JSON-serialised attestation report, if produced. Echoed
487    /// back to the client in the challenge response under
488    /// `tee_attestation`. `None` for backends with no TEE.
489    pub report: Option<serde_json::Value>,
490    /// Whether attestation succeeded for *this* challenge. The
491    /// JWT's `tee_attested` claim is sourced from this bit; a TEE
492    /// binary in `TeeMode::Optional` that fails attestation must
493    /// set this to `false`.
494    pub attested: bool,
495}
496
497impl AttestationOutcome {
498    pub fn not_attested() -> Self {
499        Self {
500            report: None,
501            attested: false,
502        }
503    }
504
505    pub fn attested(report: serde_json::Value) -> Self {
506        Self {
507            report: Some(report),
508            attested: true,
509        }
510    }
511}
512
513/// Events the canonical handlers emit to the backend's audit
514/// sink. The default `AuthBackend::audit` impl forwards each
515/// variant to `tracing::info!(audit=true)` so backends without
516/// a structured audit log get useful output for free.
517#[derive(Debug)]
518pub enum AuthAuditEvent<'a> {
519    /// Fired after a successful `/auth/challenge`. The session
520    /// is in `ChallengeSent` state.
521    ChallengeIssued { did: &'a str, session_id: &'a str },
522    /// Fired after a successful `/auth/authenticate`. The
523    /// session is now in `Authenticated` state with `amr`/`acr`
524    /// populated.
525    Authenticated {
526        did: &'a str,
527        session_id: &'a str,
528        amr: &'a [String],
529        acr: &'a str,
530    },
531    /// Fired after a successful `/auth/refresh`. The old
532    /// session has been deleted; the new one is `Authenticated`
533    /// at the *preserved* `amr`/`acr` from the old session.
534    Refreshed {
535        did: &'a str,
536        old_session_id: &'a str,
537        new_session_id: &'a str,
538        amr: &'a [String],
539        acr: &'a str,
540    },
541}
542
543// ---------------------------------------------------------------------------
544// Pre-extracted inputs the canonical handlers take
545// ---------------------------------------------------------------------------
546
547/// Inputs to `/auth/challenge`.
548#[derive(Debug, Clone)]
549pub struct ChallengeInput {
550    /// Caller's DID. ACL-gated.
551    pub did: String,
552    /// Optional ephemeral session pubkey (Ed25519 multikey
553    /// base58btc with `z` prefix) for Data-Integrity-proof
554    /// binding on subsequent trust-task envelopes. `None` for
555    /// callers that sign with their DID's own key.
556    pub session_pubkey_b58btc: Option<String>,
557}
558
559/// Inputs to `/auth/authenticate` after the transport layer has
560/// verified the signer.
561///
562/// The transport layer (DIDComm `unpack_signed`, SIOPv2 JWS
563/// verification, etc.) extracts the signer and produces this
564/// struct; the canonical handler then validates against the
565/// session and mints tokens.
566#[derive(Debug, Clone)]
567pub struct AuthenticateInput {
568    pub session_id: String,
569    pub challenge: String,
570    /// Verified signer DID. The transport layer must produce
571    /// this from a cryptographic check (DIDComm authcrypt,
572    /// JWS signature, etc.) — *never* echo it from the request
573    /// body unchecked.
574    pub signer_did: String,
575    /// Optional message `created_time` for DIDComm freshness
576    /// checking. `None` for REST transports.
577    pub created_time: Option<u64>,
578    /// Optional ephemeral session pubkey to register against
579    /// this session at the auth transition. SIOPv2 callers
580    /// (did-hosting-control) carry one to support
581    /// Data-Integrity-proof binding on subsequent
582    /// trust-task envelopes; DIDComm transports normally leave
583    /// this `None`. The route layer is responsible for any
584    /// shape-validation (e.g. `z6Mk…` Ed25519 multikey prefix)
585    /// before passing it in.
586    pub session_pubkey_b58btc: Option<String>,
587}
588
589/// Inputs to `/auth/refresh` after the transport layer has
590/// verified the signer.
591#[derive(Debug, Clone)]
592pub struct RefreshInput {
593    pub refresh_token: String,
594    /// Verified signer DID (DIDComm transports). REST transports
595    /// can leave this `None`; the canonical handler treats `None`
596    /// as "skip signer-DID-matches-session-DID check" — only safe
597    /// when the transport offers no signer assertion (i.e. plain
598    /// REST refresh, where the token itself is the only credential).
599    pub signer_did: Option<String>,
600}