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#[derive(Debug, thiserror::Error)]
61pub enum AuthError {
62    /// DID is not in the backend's ACL, or the ACL entry is expired.
63    /// Returned as 403 Forbidden to avoid revealing whether the DID
64    /// exists in the ACL system at all (timing-side-channel
65    /// mitigation; the ACL check happens before any other gate).
66    #[error("forbidden")]
67    Forbidden,
68
69    /// The DID's method (e.g. `did:foo:...`) is not in the backend's
70    /// allowlist. Distinct from `Forbidden` so audit logs can
71    /// distinguish "wrong method" from "not in ACL". Surfaced to
72    /// callers as a generic 403 to avoid leaking the allowlist
73    /// contents.
74    #[error("did method rejected")]
75    DidMethodRejected,
76
77    /// Too many concurrent `ChallengeSent` sessions for this DID;
78    /// the per-DID rate limit (default 10) is exhausted. Returned
79    /// as 429 Too Many Requests so clients can back off.
80    #[error("too many pending challenges")]
81    PendingChallengeLimitReached,
82
83    /// The session referenced by the request does not exist or has
84    /// expired (TTL swept it). Returned as 401 Unauthorized; the
85    /// holder must restart the challenge flow.
86    #[error("session not found")]
87    SessionNotFound,
88
89    /// The session exists but was already authenticated (replay) or
90    /// is otherwise not in the state the request expected. Returned
91    /// as 401 Unauthorized; the holder must restart.
92    #[error("session replay or state mismatch")]
93    SessionStateMismatch,
94
95    /// The presented challenge does not match what was issued for
96    /// this session. Constant-time compared. Returned as 401
97    /// Unauthorized.
98    #[error("challenge mismatch")]
99    ChallengeMismatch,
100
101    /// The challenge is older than the backend's configured TTL.
102    /// Returned as 401 Unauthorized; the holder must request a
103    /// fresh challenge.
104    #[error("challenge expired")]
105    ChallengeExpired,
106
107    /// The signer DID extracted from the transport (DIDComm `from`,
108    /// SIOPv2 `iss`) does not match the DID the session was issued
109    /// to. Critical binding — without this check, any leaked
110    /// challenge could be redeemed by any signer. Returned as 401
111    /// Unauthorized.
112    #[error("signer DID does not match session DID")]
113    SignerMismatch,
114
115    /// The DIDComm envelope's `created_time` is outside the freshness
116    /// window. Replay defense for the DIDComm transport. Returned as
117    /// 401 Unauthorized.
118    #[error("message created_time outside freshness window")]
119    StaleMessage,
120
121    /// The refresh token was not found or already consumed. Atomic
122    /// claim semantics: at most one caller succeeds per token.
123    /// Returned as 401 Unauthorized; the holder must re-authenticate.
124    #[error("refresh token not found or consumed")]
125    RefreshTokenInvalid,
126
127    /// The refresh token's absolute expiry has passed. Returned as
128    /// 401 Unauthorized; the holder must re-authenticate.
129    #[error("refresh token expired")]
130    RefreshTokenExpired,
131
132    /// TEE attestation failed in a `TeeMode::Required` deployment.
133    /// Returned as 503 Service Unavailable (the operator's TEE is
134    /// broken; the caller did nothing wrong).
135    #[error("tee attestation failed: {0}")]
136    AttestationFailed(String),
137
138    /// Surface for any wrapped error from the backend's policy or
139    /// storage layer that doesn't fit the variants above. The
140    /// canonical handler does not introspect this; it surfaces
141    /// unchanged via the backend's `Error::from(AuthError::Internal)`.
142    #[error("internal: {0}")]
143    Internal(String),
144}
145
146// ---------------------------------------------------------------------------
147// SessionStore — the storage primitives the canonical handlers need
148// ---------------------------------------------------------------------------
149
150/// Storage operations the canonical handlers invoke.
151///
152/// Each backend wraps its own keyspace handle (vti-common's
153/// `KeyspaceHandle` enum, did-hosting's `KeyspaceHandle` struct,
154/// future cloud-store backends) in an adapter implementing this
155/// trait. The handler holds a `&S` and never touches the concrete
156/// storage type directly.
157///
158/// ## Why this trait, not a single `KeyspaceHandle` type
159///
160/// did-hosting and vti-common evolved separate keyspace
161/// abstractions before the auth-architecture consolidation. Merging
162/// them is out of scope for the auth work; the trait boundary
163/// keeps them independent while still sharing the auth-flow code.
164#[async_trait]
165pub trait SessionStore: Send + Sync + 'static {
166    /// Wrapped error type. Conversion to `AuthError::Internal` is
167    /// the handler's responsibility (via `?` and the backend's
168    /// `From<AuthError>` impl).
169    type Error: Debug + Send + Sync + 'static;
170
171    /// Persist a session under its `session_id`.
172    async fn store_session(&self, session: &Session) -> Result<(), Self::Error>;
173
174    /// Load a session by `session_id`. `Ok(None)` if missing or expired-and-swept.
175    async fn get_session(&self, session_id: &str) -> Result<Option<Session>, Self::Error>;
176
177    /// Delete a session and its refresh-token reverse-index.
178    async fn delete_session(&self, session_id: &str) -> Result<(), Self::Error>;
179
180    /// Persist the `refresh_token → session_id` reverse-index.
181    /// Implementors choose whether to hash the key (recommended;
182    /// vti-common does, did-hosting historically does not) — the
183    /// handler treats the token as an opaque bearer.
184    async fn store_refresh_index(
185        &self,
186        refresh_token: &str,
187        session_id: &str,
188    ) -> Result<(), Self::Error>;
189
190    /// Atomically claim-and-delete the `refresh_token → session_id`
191    /// reverse-index. Cross-replica safe (Redis GETDEL / DynamoDB
192    /// DeleteItem ReturnValues=ALL_OLD / fjall mutex). Exactly one
193    /// concurrent caller observes `Some` for any given token.
194    /// Used by `/auth/refresh` to close the rotation TOCTOU.
195    async fn take_session_id_by_refresh(
196        &self,
197        refresh_token: &str,
198    ) -> Result<Option<String>, Self::Error>;
199
200    /// Count `ChallengeSent` sessions for `did`. Backends with an
201    /// O(1) per-DID tracker (did-hosting) override the default
202    /// O(N) prefix-scan implementation by re-implementing this
203    /// method.
204    ///
205    /// Default implementation provided for backends that haven't
206    /// yet built a tracker — correct but slow under load. Override
207    /// before relying on per-DID rate limiting in production.
208    async fn count_pending_challenges(&self, did: &str) -> Result<usize, Self::Error>;
209}
210
211// ---------------------------------------------------------------------------
212// AuthBackend — per-service policy + glue
213// ---------------------------------------------------------------------------
214
215/// Pluggable backend for the canonical `/auth/*` handlers.
216///
217/// One implementation per service. Most methods have safe defaults;
218/// implementors override only the policy hooks their service
219/// actually exercises (TEE attestation, DID-method allowlist, etc.).
220#[async_trait]
221pub trait AuthBackend: Send + Sync + 'static {
222    /// Session storage adapter.
223    type Store: SessionStore;
224
225    /// Backend-local error type. Must convert from [`AuthError`]
226    /// so the canonical handler can raise auth-specific failures.
227    /// Must implement `IntoResponse` at the route boundary; the
228    /// trait does not bound that here (would force an axum
229    /// dependency on every backend), but the canonical handler
230    /// surfaces the error verbatim and the route layer renders
231    /// it via its existing path.
232    type Error: From<AuthError> + Debug + Send + Sync + 'static;
233
234    /// Backend's role type. The handler holds it opaquely between
235    /// ACL lookup and JWT minting.
236    ///
237    /// - `Display` so the handler can render it into the JWT
238    ///   `role` claim (which is a plain string per the canonical
239    ///   spec).
240    /// - `Serialize` so the audit hook can include it in
241    ///   structured logs.
242    type Role: std::fmt::Display + Serialize + Clone + Send + Sync + 'static;
243
244    // -------- Plumbing --------
245
246    /// Session store handle. The handler invokes the
247    /// [`SessionStore`] methods through this.
248    fn sessions(&self) -> &Self::Store;
249
250    /// Mint an access token JWT for an authenticated session.
251    ///
252    /// The trait abstracts over the concrete JWT minter — VTA + VTC
253    /// use `vti_common::auth::jwt::JwtKeys`; did-hosting has its own
254    /// minter type with the same shape but a separate `AppError`
255    /// surface. Each backend implements this method using whatever
256    /// minter it holds; the canonical handler treats the return
257    /// value as an opaque base64url-encoded JWS.
258    #[allow(clippy::too_many_arguments)]
259    async fn mint_access_token(
260        &self,
261        subject: &str,
262        session_id: &str,
263        role: &Self::Role,
264        contexts: &[String],
265        amr: &[String],
266        acr: &str,
267        tee_attested: bool,
268        ttl_secs: u64,
269        // Per-issue `jti` nonce; embedded in the token and pinned to the
270        // session's `token_id` so a freshly-minted token supersedes the prior.
271        jti: &str,
272    ) -> Result<String, Self::Error>;
273
274    // -------- Policy hooks --------
275
276    /// Resolve a DID to a role + context scope. Returning an error
277    /// (typically [`AuthError::Forbidden`]) rejects the request
278    /// before any other gate fires.
279    async fn check_acl(&self, did: &str) -> Result<RoleResolution<Self::Role>, Self::Error>;
280
281    /// Optional DID-method validation gate. Default: accept any
282    /// method (backends with no allowlist). VTA overrides in TEE
283    /// mode to enforce `allowed_did_methods`.
284    async fn validate_did(&self, _did: &str) -> Result<(), Self::Error> {
285        Ok(())
286    }
287
288    /// Optional TEE attestation hook. Returns the attestation
289    /// report (if produced) and whether attestation succeeded.
290    /// Default: no attestation (None, false). VTA overrides in
291    /// TEE mode; in `TeeMode::Required` a failure here must be
292    /// raised as [`AuthError::AttestationFailed`].
293    async fn attest_challenge(
294        &self,
295        _challenge_bytes: &[u8; 32],
296    ) -> Result<AttestationOutcome, Self::Error> {
297        Ok(AttestationOutcome::not_attested())
298    }
299
300    /// Cap on concurrent `ChallengeSent` sessions per DID. Default
301    /// 10. Setting to 0 disables per-DID rate limiting (still
302    /// IP-rate-limited at the tower-governor layer). Backends with
303    /// low-trust callers may want this higher; backends with
304    /// admin-only callers can keep it at 10.
305    fn max_pending_challenges_per_did(&self) -> usize {
306        10
307    }
308
309    /// Audit hook fired at the end of each handler. Default impl
310    /// emits via `tracing::info!(audit=true)`; backends with
311    /// structured audit pipelines (e.g. VTC's audit log with HMAC
312    /// actor hashing) can override.
313    fn audit(&self, event: AuthAuditEvent<'_>) {
314        match event {
315            AuthAuditEvent::ChallengeIssued { did, session_id } => {
316                tracing::info!(audit = true, %did, %session_id, "auth challenge issued");
317            }
318            AuthAuditEvent::Authenticated {
319                did, session_id, ..
320            } => {
321                tracing::info!(audit = true, %did, %session_id, "auth successful");
322            }
323            AuthAuditEvent::Refreshed {
324                did,
325                old_session_id,
326                new_session_id,
327                ..
328            } => {
329                tracing::info!(
330                    audit = true,
331                    %did,
332                    %old_session_id,
333                    %new_session_id,
334                    "token refreshed",
335                );
336            }
337        }
338    }
339
340    // -------- Timings --------
341
342    /// Challenge TTL in seconds. Typical: 60.
343    fn challenge_ttl(&self) -> u64;
344
345    /// Access-token TTL in seconds. Typical: 900 (15 min).
346    fn access_token_ttl(&self) -> u64;
347
348    /// Access-token TTL in seconds for a stepped-up
349    /// (`acr=aal2`) session. Default: 1/3 of [`Self::access_token_ttl`]
350    /// floored to a minimum of 60 seconds — closes M2 from the
351    /// May 2026 security review, which observed that a leaked
352    /// `aal2` token has the same 15-minute window as a `aal1`
353    /// token despite the elevated privileges it grants.
354    ///
355    /// Backends can override to set their own ratio or to
356    /// disable the elevation (return `access_token_ttl()` for a
357    /// uniform TTL).
358    fn access_token_ttl_for_aal2(&self) -> u64 {
359        let base = self.access_token_ttl();
360        std::cmp::max(60, base / 3)
361    }
362
363    /// Refresh-token TTL in seconds. Typical: 86400 (24 h).
364    fn refresh_token_ttl(&self) -> u64;
365
366    /// DIDComm `created_time` freshness window in seconds. The
367    /// canonical handler rejects messages older than this against
368    /// `session.created_at` to bound replay risk. Default 60s.
369    fn didcomm_freshness_window(&self) -> u64 {
370        60
371    }
372}
373
374// ---------------------------------------------------------------------------
375// Supporting types
376// ---------------------------------------------------------------------------
377
378/// Result of an ACL lookup. The handler propagates this opaquely
379/// into the JWT minter and audit event.
380#[derive(Debug, Clone)]
381pub struct RoleResolution<R> {
382    pub role: R,
383    /// Context-scoped backends (VTA) populate this. Backends with
384    /// flat ACL (did-hosting) leave it empty.
385    pub contexts: Vec<String>,
386}
387
388impl<R> RoleResolution<R> {
389    pub fn new(role: R) -> Self {
390        Self {
391            role,
392            contexts: Vec::new(),
393        }
394    }
395
396    pub fn with_contexts(role: R, contexts: Vec<String>) -> Self {
397        Self { role, contexts }
398    }
399}
400
401/// Outcome of the optional TEE attestation hook.
402#[derive(Debug, Clone)]
403pub struct AttestationOutcome {
404    /// JSON-serialised attestation report, if produced. Echoed
405    /// back to the client in the challenge response under
406    /// `tee_attestation`. `None` for backends with no TEE.
407    pub report: Option<serde_json::Value>,
408    /// Whether attestation succeeded for *this* challenge. The
409    /// JWT's `tee_attested` claim is sourced from this bit; a TEE
410    /// binary in `TeeMode::Optional` that fails attestation must
411    /// set this to `false`.
412    pub attested: bool,
413}
414
415impl AttestationOutcome {
416    pub fn not_attested() -> Self {
417        Self {
418            report: None,
419            attested: false,
420        }
421    }
422
423    pub fn attested(report: serde_json::Value) -> Self {
424        Self {
425            report: Some(report),
426            attested: true,
427        }
428    }
429}
430
431/// Events the canonical handlers emit to the backend's audit
432/// sink. The default `AuthBackend::audit` impl forwards each
433/// variant to `tracing::info!(audit=true)` so backends without
434/// a structured audit log get useful output for free.
435#[derive(Debug)]
436pub enum AuthAuditEvent<'a> {
437    /// Fired after a successful `/auth/challenge`. The session
438    /// is in `ChallengeSent` state.
439    ChallengeIssued { did: &'a str, session_id: &'a str },
440    /// Fired after a successful `/auth/authenticate`. The
441    /// session is now in `Authenticated` state with `amr`/`acr`
442    /// populated.
443    Authenticated {
444        did: &'a str,
445        session_id: &'a str,
446        amr: &'a [String],
447        acr: &'a str,
448    },
449    /// Fired after a successful `/auth/refresh`. The old
450    /// session has been deleted; the new one is `Authenticated`
451    /// at the *preserved* `amr`/`acr` from the old session.
452    Refreshed {
453        did: &'a str,
454        old_session_id: &'a str,
455        new_session_id: &'a str,
456        amr: &'a [String],
457        acr: &'a str,
458    },
459}
460
461// ---------------------------------------------------------------------------
462// Pre-extracted inputs the canonical handlers take
463// ---------------------------------------------------------------------------
464
465/// Inputs to `/auth/challenge`.
466#[derive(Debug, Clone)]
467pub struct ChallengeInput {
468    /// Caller's DID. ACL-gated.
469    pub did: String,
470    /// Optional ephemeral session pubkey (Ed25519 multikey
471    /// base58btc with `z` prefix) for Data-Integrity-proof
472    /// binding on subsequent trust-task envelopes. `None` for
473    /// callers that sign with their DID's own key.
474    pub session_pubkey_b58btc: Option<String>,
475}
476
477/// Inputs to `/auth/authenticate` after the transport layer has
478/// verified the signer.
479///
480/// The transport layer (DIDComm `unpack_signed`, SIOPv2 JWS
481/// verification, etc.) extracts the signer and produces this
482/// struct; the canonical handler then validates against the
483/// session and mints tokens.
484#[derive(Debug, Clone)]
485pub struct AuthenticateInput {
486    pub session_id: String,
487    pub challenge: String,
488    /// Verified signer DID. The transport layer must produce
489    /// this from a cryptographic check (DIDComm authcrypt,
490    /// JWS signature, etc.) — *never* echo it from the request
491    /// body unchecked.
492    pub signer_did: String,
493    /// Optional message `created_time` for DIDComm freshness
494    /// checking. `None` for REST transports.
495    pub created_time: Option<u64>,
496    /// Optional ephemeral session pubkey to register against
497    /// this session at the auth transition. SIOPv2 callers
498    /// (did-hosting-control) carry one to support
499    /// Data-Integrity-proof binding on subsequent
500    /// trust-task envelopes; DIDComm transports normally leave
501    /// this `None`. The route layer is responsible for any
502    /// shape-validation (e.g. `z6Mk…` Ed25519 multikey prefix)
503    /// before passing it in.
504    pub session_pubkey_b58btc: Option<String>,
505}
506
507/// Inputs to `/auth/refresh` after the transport layer has
508/// verified the signer.
509#[derive(Debug, Clone)]
510pub struct RefreshInput {
511    pub refresh_token: String,
512    /// Verified signer DID (DIDComm transports). REST transports
513    /// can leave this `None`; the canonical handler treats `None`
514    /// as "skip signer-DID-matches-session-DID check" — only safe
515    /// when the transport offers no signer assertion (i.e. plain
516    /// REST refresh, where the token itself is the only credential).
517    pub signer_did: Option<String>,
518}