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 cap (default 10) is reached. Raised only for a
85    /// store that cannot list a subject's challenges
86    /// ([`SessionStore::pending_challenges`] returns `None`); one that
87    /// can evicts the oldest instead. How it is rendered is the
88    /// backend's error type's call — vti-common's `AppError` answers 400.
89    #[error("too many pending challenges")]
90    PendingChallengeLimitReached,
91
92    /// The session referenced by the request does not exist or has
93    /// expired (TTL swept it). Returned as 401 Unauthorized; the
94    /// holder must restart the challenge flow.
95    #[error("session not found")]
96    SessionNotFound,
97
98    /// The session exists but was already authenticated (replay) or
99    /// is otherwise not in the state the request expected. Returned
100    /// as 401 Unauthorized; the holder must restart.
101    #[error("session replay or state mismatch")]
102    SessionStateMismatch,
103
104    /// The presented challenge does not match what was issued for
105    /// this session. Constant-time compared. Returned as 401
106    /// Unauthorized.
107    #[error("challenge mismatch")]
108    ChallengeMismatch,
109
110    /// The challenge is older than the backend's configured TTL.
111    /// Returned as 401 Unauthorized; the holder must request a
112    /// fresh challenge.
113    #[error("challenge expired")]
114    ChallengeExpired,
115
116    /// The signer DID extracted from the transport (DIDComm `from`,
117    /// SIOPv2 `iss`) does not match the DID the session was issued
118    /// to. Critical binding — without this check, any leaked
119    /// challenge could be redeemed by any signer. Returned as 401
120    /// Unauthorized.
121    #[error("signer DID does not match session DID")]
122    SignerMismatch,
123
124    /// A proof-signed REST `auth/authenticate` document carried no in-band
125    /// `recipient`. SPEC §4.8 and §7.2 item 5 require one — without it the
126    /// document is valid at every service, which is the relay #1638 closes.
127    /// Returned as 400 (`malformedRequest`).
128    #[error("authenticate document names no recipient")]
129    MissingRecipient,
130
131    /// A proof-signed REST `auth/authenticate` document is addressed to
132    /// another service. Returned as 401 (`wrongRecipient`). Told plainly: it
133    /// is about the document the caller holds, not about who is enrolled here.
134    #[error("authenticate document is addressed to {recipient}, not to {own}")]
135    WrongRecipient { recipient: String, own: String },
136
137    /// The DIDComm envelope's `created_time` is outside the freshness
138    /// window. Replay defense for the DIDComm transport. Returned as
139    /// 401 Unauthorized.
140    #[error("message created_time outside freshness window")]
141    StaleMessage,
142
143    /// The refresh token was not found or already consumed. Atomic
144    /// claim semantics: at most one caller succeeds per token.
145    /// Returned as 401 Unauthorized; the holder must re-authenticate.
146    #[error("refresh token not found or consumed")]
147    RefreshTokenInvalid,
148
149    /// The refresh token's absolute expiry has passed. Returned as
150    /// 401 Unauthorized; the holder must re-authenticate.
151    #[error("refresh token expired")]
152    RefreshTokenExpired,
153
154    /// The session went longer than [`AuthBackend::idle_timeout`]
155    /// without user activity, so it may no longer be renewed.
156    ///
157    /// Distinct from [`Self::RefreshTokenExpired`] because the two mean
158    /// different things to an operator and have different fixes: a
159    /// refresh token that ran out is a session that lived its full
160    /// span, while this is one abandoned mid-life. A console that
161    /// collapsed them would tell someone who stepped away for lunch
162    /// that their session had reached its maximum age.
163    #[error("session idle timeout exceeded")]
164    SessionIdleTimeout,
165
166    /// TEE attestation failed in a `TeeMode::Required` deployment.
167    /// Returned as 503 Service Unavailable (the operator's TEE is
168    /// broken; the caller did nothing wrong).
169    #[error("tee attestation failed: {0}")]
170    AttestationFailed(String),
171
172    /// Surface for any wrapped error from the backend's policy or
173    /// storage layer that doesn't fit the variants above. The
174    /// canonical handler does not introspect this; it surfaces
175    /// unchanged via the backend's `Error::from(AuthError::Internal)`.
176    #[error("internal: {0}")]
177    Internal(String),
178}
179
180// ---------------------------------------------------------------------------
181// SessionStore — the storage primitives the canonical handlers need
182// ---------------------------------------------------------------------------
183
184/// Storage operations the canonical handlers invoke.
185///
186/// Each backend wraps its own keyspace handle (vti-common's
187/// `KeyspaceHandle` enum, did-hosting's `KeyspaceHandle` struct,
188/// future cloud-store backends) in an adapter implementing this
189/// trait. The handler holds a `&S` and never touches the concrete
190/// storage type directly.
191///
192/// ## Why this trait, not a single `KeyspaceHandle` type
193///
194/// did-hosting and vti-common evolved separate keyspace
195/// abstractions before the auth-architecture consolidation. Merging
196/// them is out of scope for the auth work; the trait boundary
197/// keeps them independent while still sharing the auth-flow code.
198#[async_trait]
199pub trait SessionStore: Send + Sync + 'static {
200    /// Wrapped error type. Conversion to `AuthError::Internal` is
201    /// the handler's responsibility (via `?` and the backend's
202    /// `From<AuthError>` impl).
203    type Error: Debug + Send + Sync + 'static;
204
205    /// Persist a session under its `session_id`.
206    async fn store_session(&self, session: &Session) -> Result<(), Self::Error>;
207
208    /// Load a session by `session_id`. `Ok(None)` if missing or expired-and-swept.
209    async fn get_session(&self, session_id: &str) -> Result<Option<Session>, Self::Error>;
210
211    /// Delete a session and its refresh-token reverse-index.
212    async fn delete_session(&self, session_id: &str) -> Result<(), Self::Error>;
213
214    /// Atomically take (read **and** remove) a session row — the claim that
215    /// makes a single-use challenge single-use under concurrency.
216    ///
217    /// `/auth/` consumes the challenge row with this **before** it looks up
218    /// the ACL and mints, so of any number of concurrent presentations of the
219    /// same signed envelope exactly one proceeds. See
220    /// [`Self::take_session_id_by_refresh`], which closes the same TOCTOU on
221    /// the refresh path.
222    ///
223    /// **The default implementation is not atomic.** It is a `get_session`
224    /// followed by a `delete_session`, which preserves today's behaviour for
225    /// every backend that does not override it — correct sequentially, but two
226    /// sufficiently interleaved callers can both observe `Some`. A backend
227    /// whose store offers a native claim (Redis `GETDEL`, DynamoDB
228    /// `DeleteItem ReturnValues=ALL_OLD`, a mutex over an embedded store)
229    /// should override it; `KeyspaceSessionStore` does.
230    async fn take_session(&self, session_id: &str) -> Result<Option<Session>, Self::Error> {
231        let session = self.get_session(session_id).await?;
232        if session.is_some() {
233            self.delete_session(session_id).await?;
234        }
235        Ok(session)
236    }
237
238    /// Persist the `refresh_token → session_id` reverse-index.
239    /// Implementors choose whether to hash the key (recommended;
240    /// vti-common does, did-hosting historically does not) — the
241    /// handler treats the token as an opaque bearer.
242    async fn store_refresh_index(
243        &self,
244        refresh_token: &str,
245        session_id: &str,
246    ) -> Result<(), Self::Error>;
247
248    /// Atomically claim-and-delete the `refresh_token → session_id`
249    /// reverse-index. Cross-replica safe (Redis GETDEL / DynamoDB
250    /// DeleteItem ReturnValues=ALL_OLD / fjall mutex). Exactly one
251    /// concurrent caller observes `Some` for any given token.
252    /// Used by `/auth/refresh` to close the rotation TOCTOU.
253    async fn take_session_id_by_refresh(
254        &self,
255        refresh_token: &str,
256    ) -> Result<Option<String>, Self::Error>;
257
258    /// Hash ([`crate::auth::session::refresh_token_hash`]) of the refresh
259    /// token `session_id` currently issues — the one the most recent login
260    /// or rotation handed out.
261    ///
262    /// `/auth/refresh` refuses a claimed token that is not current, which is
263    /// what makes one live chain per session hold by construction: an index
264    /// entry left behind by an earlier login, or written by a rotation that
265    /// lost a race with a login, still claims but no longer mints.
266    ///
267    /// Must be recorded apart from the session row, by whatever writes the
268    /// refresh index, and never by the row's read-modify-write writers (an
269    /// activity touch, a step-up) — they can write an older `refresh_token`
270    /// back into the row, which is why the row cannot be the authority.
271    ///
272    /// **Default: `Ok(None)`**, meaning "not recorded". The handler then
273    /// falls back to comparing against `Session::refresh_token`, which is
274    /// correct except under that read-modify-write race. A backend should
275    /// override this together with [`Self::store_refresh_index`].
276    async fn current_refresh_hash(&self, _session_id: &str) -> Result<Option<String>, Self::Error> {
277        Ok(None)
278    }
279
280    /// Record that `rotated_token` was valid here and has been retired,
281    /// for the benefit of [`Self::get_refresh_tombstone`].
282    ///
283    /// Invoked by `/auth/refresh` after the replacement token's index is
284    /// durable, and by `/auth/` when a fresh login supersedes the
285    /// previous token, so a crash between the two costs detection of a
286    /// future replay but never the session itself.
287    ///
288    /// Implementors MUST NOT store either token in recoverable form —
289    /// `rotated_token` belongs in a one-way key and the successor is
290    /// already carried as a hash (see
291    /// [`crate::auth::session::RefreshTombstone`]).
292    ///
293    /// **Default: a no-op.** Reuse detection is an enhancement over the
294    /// rotation that already protects every backend, and a store with
295    /// no room for the extra row should degrade to "rotation only"
296    /// rather than fail closed on a live auth path. A backend that
297    /// leaves both this and [`Self::get_refresh_tombstone`] at their
298    /// defaults behaves exactly as it did before detection existed:
299    /// replay is refused, just not attributed. Override both together —
300    /// implementing one alone achieves nothing.
301    async fn store_refresh_tombstone(
302        &self,
303        _rotated_token: &str,
304        _tombstone: &crate::auth::session::RefreshTombstone,
305    ) -> Result<(), Self::Error> {
306        Ok(())
307    }
308
309    /// Look up the tombstone for a refresh token that is not in the live
310    /// index, to tell "this node rotated this token out" from "this node
311    /// never issued this token".
312    ///
313    /// `Ok(None)` is the safe answer in every ambiguous case: it yields
314    /// a plain rejection, which is what an unrecognised token gets
315    /// anyway. **Default: always `Ok(None)`** — see
316    /// [`Self::store_refresh_tombstone`].
317    async fn get_refresh_tombstone(
318        &self,
319        _refresh_token: &str,
320    ) -> Result<Option<crate::auth::session::RefreshTombstone>, Self::Error> {
321        Ok(None)
322    }
323
324    /// Count `ChallengeSent` sessions for `did`. Backends with an
325    /// O(1) per-DID tracker (did-hosting) override the default
326    /// O(N) prefix-scan implementation by re-implementing this
327    /// method.
328    ///
329    /// Default implementation provided for backends that haven't
330    /// yet built a tracker — correct but slow under load. Override
331    /// before relying on per-DID rate limiting in production.
332    async fn count_pending_challenges(&self, did: &str) -> Result<usize, Self::Error>;
333
334    /// The `ChallengeSent` sessions of `did`, oldest first, expired ones
335    /// included; `None` if this store cannot enumerate them.
336    ///
337    /// With `Some`, `/auth/challenge` holds the per-DID cap
338    /// ([`AuthBackend::max_pending_challenges_per_did`], VTI-SES-005) by
339    /// discarding expired challenges and evicting the oldest outstanding one,
340    /// so a subject is never refused a challenge. With `None` it falls back to
341    /// [`Self::count_pending_challenges`] and refuses at the cap with
342    /// [`AuthError::PendingChallengeLimitReached`] — a refusal only an enrolled
343    /// subject can provoke, and which holds until the sweep removes the rows.
344    ///
345    /// Either way the handler holds an in-process lock per subject from this
346    /// call until the new challenge is stored, so the cap is exact for a store
347    /// one process owns. A store shared by several processes needs the cap
348    /// held by the store itself.
349    ///
350    /// **Default: `Ok(None)`.** [`crate::auth::session::list_pending_challenges`]
351    /// implements it for a [`crate::store::KeyspaceHandle`] whose rows were
352    /// written through [`crate::auth::session`]. Rows written before that index
353    /// existed are not listed, so for one `challenge_ttl` after an upgrade a
354    /// subject can hold up to twice the cap; those rows then expire.
355    ///
356    /// Must not write: a caller outside the handler's lock may list too.
357    async fn pending_challenges(&self, _did: &str) -> Result<Option<Vec<Session>>, Self::Error> {
358        Ok(None)
359    }
360
361    /// Record that `session_id` saw **user activity** at `at` (epoch
362    /// seconds), by writing [`Session::last_seen`].
363    ///
364    /// This is what [`AuthBackend::idle_timeout`] measures against, so
365    /// only genuine interaction may call it. An admin console that
366    /// polls a status endpoint on a timer would, if that poll counted,
367    /// hold a session open for as long as the tab stayed open — which
368    /// is precisely the thing an idle timeout exists to prevent.
369    ///
370    /// The default is a read-modify-write, which is **not** atomic
371    /// against a concurrent session mutation: a racing write can lose
372    /// the `last_seen` bump. That is the safe direction to lose in — a
373    /// dropped bump expires a session early, never late — and it
374    /// follows the [`Self::count_pending_challenges`] precedent of a
375    /// correct-but-unoptimised default. A backend with a
376    /// compare-and-set or partial-update primitive should override.
377    ///
378    /// A missing session is `Ok(())`, not an error: the row may have
379    /// been swept or revoked between authentication and this call, and
380    /// there is nothing to record.
381    async fn touch_session(&self, session_id: &str, at: u64) -> Result<(), Self::Error> {
382        let Some(mut session) = self.get_session(session_id).await? else {
383            return Ok(());
384        };
385        if session.last_seen >= at {
386            return Ok(());
387        }
388        session.last_seen = at;
389        self.store_session(&session).await
390    }
391}
392
393// ---------------------------------------------------------------------------
394// AuthBackend — per-service policy + glue
395// ---------------------------------------------------------------------------
396
397/// Pluggable backend for the canonical `/auth/*` handlers.
398///
399/// One implementation per service. Most methods have safe defaults;
400/// implementors override only the policy hooks their service
401/// actually exercises (TEE attestation, DID-method allowlist, etc.).
402#[async_trait]
403pub trait AuthBackend: Send + Sync + 'static {
404    /// Session storage adapter.
405    type Store: SessionStore;
406
407    /// Backend-local error type. Must convert from [`AuthError`]
408    /// so the canonical handler can raise auth-specific failures.
409    /// Must implement `IntoResponse` at the route boundary; the
410    /// trait does not bound that here (would force an axum
411    /// dependency on every backend), but the canonical handler
412    /// surfaces the error verbatim and the route layer renders
413    /// it via its existing path.
414    type Error: From<AuthError> + Debug + Send + Sync + 'static;
415
416    /// Backend's role type. The handler holds it opaquely between
417    /// ACL lookup and JWT minting.
418    ///
419    /// - `Display` so the handler can render it into the JWT
420    ///   `role` claim (which is a plain string per the canonical
421    ///   spec).
422    /// - `Serialize` so the audit hook can include it in
423    ///   structured logs.
424    type Role: std::fmt::Display + Serialize + Clone + Send + Sync + 'static;
425
426    // -------- Plumbing --------
427
428    /// Session store handle. The handler invokes the
429    /// [`SessionStore`] methods through this.
430    fn sessions(&self) -> &Self::Store;
431
432    /// Mint an access token JWT for an authenticated session.
433    ///
434    /// The trait abstracts over the concrete JWT minter — VTA + VTC
435    /// use `vti_common::auth::jwt::JwtKeys`; did-hosting has its own
436    /// minter type with the same shape but a separate `AppError`
437    /// surface. Each backend implements this method using whatever
438    /// minter it holds; the canonical handler treats the return
439    /// value as an opaque base64url-encoded JWS.
440    #[allow(clippy::too_many_arguments)]
441    async fn mint_access_token(
442        &self,
443        subject: &str,
444        session_id: &str,
445        role: &Self::Role,
446        contexts: &[String],
447        amr: &[String],
448        acr: &str,
449        tee_attested: bool,
450        ttl_secs: u64,
451        // Per-issue `jti` nonce; embedded in the token and pinned to the
452        // session's `token_id` so a freshly-minted token supersedes the prior.
453        jti: &str,
454    ) -> Result<String, Self::Error>;
455
456    // -------- Policy hooks --------
457
458    /// Resolve a DID to a role + context scope. Returning an error
459    /// (typically [`AuthError::Forbidden`]) rejects the request
460    /// before any other gate fires.
461    async fn check_acl(&self, did: &str) -> Result<RoleResolution<Self::Role>, Self::Error>;
462
463    /// Whether `did` currently has an effective ACL entry.
464    ///
465    /// Used by [`crate::auth::handlers::handle_challenge`], which needs to
466    /// know whether to persist a session but **must not disclose the answer
467    /// to the caller**. Returning `bool` rather than `Result` is deliberate:
468    /// the caller cannot act on the difference between "no entry" and "the
469    /// ACL store failed", and both have to produce the same response, so a
470    /// failure reads as absent and the request gets an unusable challenge.
471    /// The real error surfaces at `/auth/authenticate`, where the caller has
472    /// already proven control of the DID.
473    ///
474    /// Default: `check_acl(did).await.is_ok()`.
475    async fn has_effective_entry(&self, did: &str) -> bool {
476        self.check_acl(did).await.is_ok()
477    }
478
479    /// Optional DID-method validation gate. Default: accept any
480    /// method (backends with no allowlist). VTA overrides in TEE
481    /// mode to enforce `allowed_did_methods`.
482    async fn validate_did(&self, _did: &str) -> Result<(), Self::Error> {
483        Ok(())
484    }
485
486    /// Optional TEE attestation hook. Returns the attestation
487    /// report (if produced) and whether attestation succeeded.
488    /// Default: no attestation (None, false). VTA overrides in
489    /// TEE mode; in `TeeMode::Required` a failure here must be
490    /// raised as [`AuthError::AttestationFailed`].
491    async fn attest_challenge(
492        &self,
493        _challenge_bytes: &[u8; 32],
494    ) -> Result<AttestationOutcome, Self::Error> {
495        Ok(AttestationOutcome::not_attested())
496    }
497
498    /// Cap on concurrent `ChallengeSent` sessions per DID. Default
499    /// 10. Setting to 0 disables per-DID rate limiting (still
500    /// IP-rate-limited at the tower-governor layer). Backends with
501    /// low-trust callers may want this higher; backends with
502    /// admin-only callers can keep it at 10.
503    fn max_pending_challenges_per_did(&self) -> usize {
504        10
505    }
506
507    /// Audit hook fired at the end of each handler. Default impl
508    /// emits via `tracing::info!(audit=true)`; backends with
509    /// structured audit pipelines (e.g. VTC's audit log with HMAC
510    /// actor hashing) can override.
511    fn audit(&self, event: AuthAuditEvent<'_>) {
512        match event {
513            AuthAuditEvent::ChallengeIssued { did, session_id } => {
514                tracing::info!(audit = true, %did, %session_id, "auth challenge issued");
515            }
516            AuthAuditEvent::Authenticated {
517                did, session_id, ..
518            } => {
519                tracing::info!(audit = true, %did, %session_id, "auth successful");
520            }
521            AuthAuditEvent::Refreshed {
522                did,
523                old_session_id,
524                new_session_id,
525                ..
526            } => {
527                tracing::info!(
528                    audit = true,
529                    %did,
530                    %old_session_id,
531                    %new_session_id,
532                    "token refreshed",
533                );
534            }
535            // `error!`, not `info!`: every other variant records a flow
536            // that completed as designed, while this one reports a
537            // refresh token in two pairs of hands. It is meant to reach
538            // an operator, so it is emitted at a level that ordinarily
539            // survives production log filtering, and tagged
540            // `security_alert` so an audit pipeline can route it
541            // without matching on the message text.
542            AuthAuditEvent::RefreshReuseDetected {
543                did,
544                session_id,
545                rotated_at,
546                reason,
547            } => {
548                tracing::error!(
549                    audit = true,
550                    security_alert = true,
551                    %did,
552                    %session_id,
553                    rotated_at,
554                    reason = reason.as_str(),
555                    "refresh token reuse detected — session revoked",
556                );
557            }
558            // `warn!`, not `error!`, and deliberately not revoking: unlike
559            // the variant above this one has a routine explanation. A
560            // second device — or a second tab — that logged in while the
561            // first was idle leaves the first holding a retired token, and
562            // presenting it is the only way that client can discover it
563            // has been superseded. The token is already dead, so the
564            // security outcome is settled before this fires; escalating to
565            // a session kill would sign the *active* client out over the
566            // stale one's first request, and a re-login would start the
567            // cycle again.
568            //
569            // Still audited, and still `security_alert`: a token presented
570            // after a re-login is also exactly what a pre-login theft looks
571            // like, and the operator correlating these is the one who can
572            // tell a spare device from a stolen token.
573            AuthAuditEvent::RefreshSuperseded {
574                did,
575                session_id,
576                rotated_at,
577            } => {
578                tracing::warn!(
579                    audit = true,
580                    security_alert = true,
581                    %did,
582                    %session_id,
583                    rotated_at,
584                    "refresh token superseded by a newer login was presented \
585                     — refused, session left running",
586                );
587            }
588        }
589    }
590
591    // -------- Timings --------
592
593    /// Challenge TTL in seconds. Typical: 60.
594    fn challenge_ttl(&self) -> u64;
595
596    /// Access-token TTL in seconds. Typical: 900 (15 min).
597    fn access_token_ttl(&self) -> u64;
598
599    /// Access-token TTL in seconds for a stepped-up
600    /// (`acr=aal2`) session. Default: 1/3 of [`Self::access_token_ttl`]
601    /// floored to a minimum of 60 seconds — closes M2 from the
602    /// May 2026 security review, which observed that a leaked
603    /// `aal2` token has the same 15-minute window as a `aal1`
604    /// token despite the elevated privileges it grants.
605    ///
606    /// Backends can override to set their own ratio or to
607    /// disable the elevation (return `access_token_ttl()` for a
608    /// uniform TTL).
609    fn access_token_ttl_for_aal2(&self) -> u64 {
610        let base = self.access_token_ttl();
611        std::cmp::max(60, base / 3)
612    }
613
614    /// Refresh-token TTL in seconds. Typical: 86400 (24 h).
615    fn refresh_token_ttl(&self) -> u64;
616
617    /// How long after a rotation the token it replaced may still be
618    /// presented without being treated as reuse. Default: 30 seconds.
619    ///
620    /// This exists for one failure that is not an attack. A client
621    /// whose rotation response is lost in flight — dropped connection,
622    /// timeout, process death between the write and the read — still
623    /// holds only the old token, and retrying with it is the correct
624    /// thing for it to do. Strict reuse detection would read that retry
625    /// as theft and sign the user out, so the common network fault
626    /// would produce the alarm while a patient attacker (who simply
627    /// waits) would not.
628    ///
629    /// The window is not the only condition: `handle_refresh` also
630    /// requires that the successor recorded in the tombstone still be
631    /// the session's live token, so a retry only passes while the
632    /// client demonstrably never received the response it is retrying
633    /// for — and it never applies to a token retired by a re-login
634    /// ([`crate::auth::session::TombstoneCause::Superseded`]).
635    /// Returning `0` disables the concession and makes every replay a
636    /// compromise signal.
637    ///
638    /// 30s is the starting point, not a ceiling. A client only learns
639    /// its response was lost when its own HTTP timeout fires, so a
640    /// deployment whose clients time out at 30s or later may see
641    /// legitimate retries land just outside the window and be signed
642    /// out. If that shows up in practice, raising this to 60s is the
643    /// intended adjustment — the successor-unspent condition, not the
644    /// clock, is what keeps the concession narrow.
645    fn refresh_reuse_grace(&self) -> u64 {
646        30
647    }
648
649    /// DIDComm `created_time` freshness window in seconds. The
650    /// canonical handler rejects messages older than this against
651    /// `session.created_at` to bound replay risk. Default 60s.
652    fn didcomm_freshness_window(&self) -> u64 {
653        60
654    }
655
656    /// Idle timeout in seconds: how long a session may go without
657    /// **user activity** before it may no longer be renewed.
658    ///
659    /// `None` (the default) means no idle enforcement — a session is
660    /// bounded only by its refresh-token expiry, which is the
661    /// behaviour every backend had before this existed.
662    ///
663    /// This is deliberately distinct from [`Self::access_token_ttl`].
664    /// An access token is short and rotates; the idle timeout is a
665    /// policy about the operator, and a browser session that renews on
666    /// a timer would otherwise never lapse no matter how long the
667    /// human had been away. `handle_refresh` measures it against
668    /// [`SessionStore::touch_session`]'s `last_seen`.
669    fn idle_timeout(&self) -> Option<u64> {
670        None
671    }
672}
673
674// ---------------------------------------------------------------------------
675// Supporting types
676// ---------------------------------------------------------------------------
677
678/// Result of an ACL lookup. The handler propagates this opaquely
679/// into the JWT minter and audit event.
680#[derive(Debug, Clone)]
681pub struct RoleResolution<R> {
682    pub role: R,
683    /// Context-scoped backends (VTA) populate this. Backends with
684    /// flat ACL (did-hosting) leave it empty.
685    pub contexts: Vec<String>,
686}
687
688impl<R> RoleResolution<R> {
689    pub fn new(role: R) -> Self {
690        Self {
691            role,
692            contexts: Vec::new(),
693        }
694    }
695
696    pub fn with_contexts(role: R, contexts: Vec<String>) -> Self {
697        Self { role, contexts }
698    }
699}
700
701/// Outcome of the optional TEE attestation hook.
702#[derive(Debug, Clone)]
703pub struct AttestationOutcome {
704    /// JSON-serialised attestation report, if produced. Echoed
705    /// back to the client in the challenge response under
706    /// `tee_attestation`. `None` for backends with no TEE.
707    pub report: Option<serde_json::Value>,
708    /// Whether attestation succeeded for *this* challenge. The
709    /// JWT's `tee_attested` claim is sourced from this bit; a TEE
710    /// binary in `TeeMode::Optional` that fails attestation must
711    /// set this to `false`.
712    pub attested: bool,
713}
714
715impl AttestationOutcome {
716    pub fn not_attested() -> Self {
717        Self {
718            report: None,
719            attested: false,
720        }
721    }
722
723    pub fn attested(report: serde_json::Value) -> Self {
724        Self {
725            report: Some(report),
726            attested: true,
727        }
728    }
729}
730
731/// Events the canonical handlers emit to the backend's audit
732/// sink. The default `AuthBackend::audit` impl forwards each
733/// variant to `tracing::info!(audit=true)` so backends without
734/// a structured audit log get useful output for free.
735///
736/// `#[non_exhaustive]` for the same reason as [`AuthError`]: the set
737/// has grown with every auth feature and each addition was otherwise a
738/// silent breaking change for backends matching it exhaustively.
739#[derive(Debug)]
740#[non_exhaustive]
741pub enum AuthAuditEvent<'a> {
742    /// Fired after a successful `/auth/challenge`. The session
743    /// is in `ChallengeSent` state.
744    ChallengeIssued { did: &'a str, session_id: &'a str },
745    /// Fired after a successful `/auth/authenticate`. The
746    /// session is now in `Authenticated` state with `amr`/`acr`
747    /// populated.
748    Authenticated {
749        did: &'a str,
750        session_id: &'a str,
751        amr: &'a [String],
752        acr: &'a str,
753    },
754    /// Fired after a successful `/auth/refresh`. The old
755    /// session has been deleted; the new one is `Authenticated`
756    /// at the *preserved* `amr`/`acr` from the old session.
757    Refreshed {
758        did: &'a str,
759        old_session_id: &'a str,
760        new_session_id: &'a str,
761        amr: &'a [String],
762        acr: &'a str,
763    },
764    /// **Security alert.** A refresh token this node had already
765    /// rotated out was presented again, outside the conditions that
766    /// make a replay an innocent retry (see
767    /// [`AuthBackend::refresh_reuse_grace`]).
768    ///
769    /// By the time this fires the session named here has been deleted
770    /// along with its live refresh index, so every token descended from
771    /// the replayed one is dead — the legitimate holder and the
772    /// attacker are signed out together, because the node cannot tell
773    /// which of the two is which and leaving the session up would mean
774    /// leaving it up for both.
775    ///
776    /// Backends with a structured audit log should route this at a
777    /// higher severity than the rest of this enum. It is the only
778    /// variant that reports a probable compromise rather than a
779    /// completed flow, and it is the signal that was missing while
780    /// rotation refused replays silently.
781    RefreshReuseDetected {
782        /// Session owner, carried on the tombstone so the alert names
783        /// the account even when the session row is already gone.
784        did: &'a str,
785        session_id: &'a str,
786        /// When the replayed token had been retired — how long the
787        /// attacker sat on it.
788        rotated_at: u64,
789        reason: RefreshReuseReason,
790    },
791    /// A refresh token retired by a **newer login on the same DID** was
792    /// presented.
793    ///
794    /// Distinct from [`Self::RefreshReuseDetected`] because the
795    /// consequence is different: the token is refused, but the session
796    /// is **left running**. The retired token is already dead — the
797    /// login removed its index entry — so nothing is gained by killing
798    /// the live session on top, and the benign reading (a second device
799    /// still holding the token it was issued before the user logged in
800    /// elsewhere) is common enough that revoking would turn an ordinary
801    /// second sign-in into a mutual sign-out.
802    ///
803    /// Audited at `warn!` with `security_alert = true` all the same: a
804    /// token surviving a re-login is also what a pre-login theft looks
805    /// like, and only the operator can tell the two apart.
806    RefreshSuperseded {
807        /// Session owner, carried on the tombstone so the alert names
808        /// the account even when the session row is already gone.
809        did: &'a str,
810        session_id: &'a str,
811        /// When the login retired this token.
812        rotated_at: u64,
813    },
814}
815
816/// Why a replayed refresh token was judged reuse rather than a retry.
817///
818/// Carried on [`AuthAuditEvent::RefreshReuseDetected`] for triage: the
819/// three cases have very different operational readings.
820#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821#[non_exhaustive]
822pub enum RefreshReuseReason {
823    /// Presented after [`AuthBackend::refresh_reuse_grace`] elapsed.
824    /// The classic stolen-token replay — a retry would have arrived in
825    /// seconds, not minutes.
826    GraceExpired,
827    /// Presented inside the grace window, but the session had already
828    /// moved past the successor token. Someone received the rotation
829    /// response and someone else is still holding the token it
830    /// replaced, which means two parties hold the chain.
831    ChainAdvanced,
832    /// The session was already gone — revoked, swept, or killed by an
833    /// earlier detection. Whoever presented this token kept it across
834    /// the end of the session it belonged to.
835    SessionGone,
836}
837
838impl RefreshReuseReason {
839    pub fn as_str(self) -> &'static str {
840        match self {
841            Self::GraceExpired => "grace_expired",
842            Self::ChainAdvanced => "chain_advanced",
843            Self::SessionGone => "session_gone",
844        }
845    }
846}
847
848impl std::fmt::Display for RefreshReuseReason {
849    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
850        f.write_str(self.as_str())
851    }
852}
853
854// ---------------------------------------------------------------------------
855// Pre-extracted inputs the canonical handlers take
856// ---------------------------------------------------------------------------
857
858/// Inputs to `/auth/challenge`.
859#[derive(Debug, Clone)]
860pub struct ChallengeInput {
861    /// Caller's DID. ACL-gated.
862    pub did: String,
863    /// Optional ephemeral session pubkey (Ed25519 multikey
864    /// base58btc with `z` prefix) for Data-Integrity-proof
865    /// binding on subsequent trust-task envelopes. `None` for
866    /// callers that sign with their DID's own key.
867    pub session_pubkey_b58btc: Option<String>,
868}
869
870/// How the document being authenticated is bound to the service that receives
871/// it (SPEC §7.2 item 5, #1638). Required on every [`AuthenticateInput`], so
872/// each sign-in path has to say which applies — a new path cannot forget.
873#[derive(Debug, Clone, PartialEq, Eq)]
874#[non_exhaustive]
875pub enum AudienceBinding {
876    /// The transport or credential binds it, and the route has checked it: an
877    /// authcrypt envelope only this service can open (DIDComm, TSP), a SIOP
878    /// `id_token` whose `aud` names this service, a WebAuthn assertion bound
879    /// to this service's RP ID.
880    Transport,
881    /// A proof-signed document over plain REST, which binds nothing by
882    /// itself: its in-band `recipient` must be this service's own DID, by
883    /// exact string equality (SPEC §4.8).
884    Recipient {
885        /// The document's `recipient`, as sent.
886        recipient: Option<String>,
887        /// This service's own DID, if it has one.
888        own_did: Option<String>,
889    },
890}
891
892impl AudienceBinding {
893    /// Refuse a document not addressed to this service.
894    pub fn check(&self) -> Result<(), AuthError> {
895        match self {
896            Self::Transport => Ok(()),
897            Self::Recipient { recipient, own_did } => {
898                let own = own_did.as_deref().ok_or_else(|| {
899                    AuthError::Internal(
900                        "this service has no DID configured, so a signed sign-in cannot be \
901                         addressed to it"
902                            .into(),
903                    )
904                })?;
905                match recipient.as_deref() {
906                    None => Err(AuthError::MissingRecipient),
907                    Some(r) if r == own => Ok(()),
908                    Some(r) => Err(AuthError::WrongRecipient {
909                        recipient: r.to_string(),
910                        own: own.to_string(),
911                    }),
912                }
913            }
914        }
915    }
916}
917
918/// Inputs to `/auth/authenticate` after the transport layer has
919/// verified the signer.
920///
921/// The transport layer (DIDComm `unpack_signed`, SIOPv2 JWS
922/// verification, etc.) extracts the signer and produces this
923/// struct; the canonical handler then validates against the
924/// session and mints tokens.
925#[derive(Debug, Clone)]
926pub struct AuthenticateInput {
927    pub session_id: String,
928    pub challenge: String,
929    /// Verified signer DID. The transport layer must produce
930    /// this from a cryptographic check (DIDComm authcrypt,
931    /// JWS signature, etc.) — *never* echo it from the request
932    /// body unchecked.
933    pub signer_did: String,
934    /// Optional message `created_time` for DIDComm freshness
935    /// checking. `None` for REST transports.
936    pub created_time: Option<u64>,
937    /// Optional ephemeral session pubkey to register against
938    /// this session at the auth transition. SIOPv2 callers
939    /// (did-hosting-control) carry one to support
940    /// Data-Integrity-proof binding on subsequent
941    /// trust-task envelopes; DIDComm transports normally leave
942    /// this `None`. The route layer is responsible for any
943    /// shape-validation (e.g. `z6Mk…` Ed25519 multikey prefix)
944    /// before passing it in.
945    pub session_pubkey_b58btc: Option<String>,
946    /// How this document is bound to this service. Checked before anything
947    /// else, so a document addressed elsewhere learns nothing about sessions.
948    pub audience: AudienceBinding,
949}
950
951/// Inputs to `/auth/refresh` after the transport layer has
952/// verified the signer.
953#[derive(Clone)]
954pub struct RefreshInput {
955    pub refresh_token: String,
956    /// Verified signer DID (DIDComm transports). REST transports
957    /// can leave this `None`; the canonical handler treats `None`
958    /// as "skip signer-DID-matches-session-DID check" — only safe
959    /// when the transport offers no signer assertion (i.e. plain
960    /// REST refresh, where the token itself is the only credential).
961    pub signer_did: Option<String>,
962}
963
964/// Written by hand so the refresh token never reaches a log: a derived `Debug` would print it.
965impl std::fmt::Debug for RefreshInput {
966    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
967        f.debug_struct("RefreshInput")
968            .field("refresh_token", &"<redacted>")
969            .field("signer_did", &self.signer_did)
970            .finish()
971    }
972}
973
974#[cfg(test)]
975mod audience_tests {
976    use super::{AudienceBinding, AuthError};
977
978    fn recipient(r: Option<&str>, own: Option<&str>) -> AudienceBinding {
979        AudienceBinding::Recipient {
980            recipient: r.map(String::from),
981            own_did: own.map(String::from),
982        }
983    }
984
985    /// #1638 / SPEC §7.2 item 5: accepted only when addressed to this service.
986    #[test]
987    fn a_signed_document_must_be_addressed_to_this_service() {
988        assert!(
989            recipient(Some("did:key:zMe"), Some("did:key:zMe"))
990                .check()
991                .is_ok()
992        );
993        assert!(matches!(
994            recipient(Some("did:key:zOther"), Some("did:key:zMe")).check(),
995            Err(AuthError::WrongRecipient { .. })
996        ));
997        assert!(matches!(
998            recipient(None, Some("did:key:zMe")).check(),
999            Err(AuthError::MissingRecipient)
1000        ));
1001        // Exact string equality: no normalisation (SPEC §4.8).
1002        assert!(
1003            recipient(Some("did:key:zMe "), Some("did:key:zMe"))
1004                .check()
1005                .is_err()
1006        );
1007        // A service with no DID cannot be addressed at all.
1008        assert!(matches!(
1009            recipient(Some("did:key:zMe"), None).check(),
1010            Err(AuthError::Internal(_))
1011        ));
1012    }
1013
1014    #[test]
1015    fn a_transport_binding_needs_no_recipient() {
1016        assert!(AudienceBinding::Transport.check().is_ok());
1017    }
1018}