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}