Skip to main content

vti_common/auth/
extractor.rs

1use std::sync::Arc;
2
3use axum::extract::FromRequestParts;
4use axum::http::request::Parts;
5use axum_extra::TypedHeader;
6use axum_extra::headers::Authorization;
7use axum_extra::headers::authorization::Bearer;
8use tracing::warn;
9
10use crate::acl::{ActScope, Role, act_scope_for};
11use crate::auth::jwt::JwtKeys;
12use crate::auth::session::{Session, SessionState, get_session, now_epoch};
13use crate::error::AppError;
14use crate::store::KeyspaceHandle;
15
16/// Trait that each service's `AppState` implements to provide the data
17/// needed by the auth extractors.
18pub trait AuthState: Clone + Send + Sync + 'static {
19    fn jwt_keys(&self) -> Option<&Arc<JwtKeys>>;
20    fn sessions_ks(&self) -> &KeyspaceHandle;
21}
22
23/// Extracted from a valid JWT Bearer token on protected routes.
24///
25/// Add this as a handler parameter to require authentication:
26/// ```ignore
27/// async fn handler(_auth: AuthClaims, ...) { }
28/// ```
29#[derive(Debug, Default, Clone)]
30pub struct AuthClaims {
31    pub did: String,
32    pub role: Role,
33    pub allowed_contexts: Vec<String>,
34    /// JWT `session_id` claim. Carried through so handlers can do
35    /// session-targeted operations (sign-out, refresh-token
36    /// rotation) without re-decoding the JWT.
37    pub session_id: String,
38    /// JWT `exp` claim — Unix-second expiry. Surfaced so
39    /// `whoami`-style endpoints can return the access-token
40    /// lifetime without re-decoding.
41    pub access_expires_at: u64,
42    /// Authentication Methods References per [RFC 8176]. Mirrors
43    /// `Claims.amr` from the bearer JWT. Handlers gating sensitive
44    /// operations check this to decide whether a step-up is needed.
45    pub amr: Vec<String>,
46    /// Authentication Context Class Reference per OIDC Core §2.
47    /// Typical values: `"aal1"` / `"aal2"` / `"aal3"`. Handlers gating
48    /// step-up read this directly.
49    pub acr: String,
50}
51
52/// Name of the admin UX session cookie set by the VTC's
53/// `POST /v1/auth/admin-session` + `POST /v1/auth/passkey-login/finish`
54/// flows. When the `Authorization: Bearer` header is absent,
55/// [`AuthClaims`] falls back to reading a JWT out of this cookie.
56/// The cookie is set with `Path=/; SameSite=Strict; Secure; HttpOnly`
57/// so the browser sends it on `/v1/*` API calls; `HttpOnly` keeps
58/// JS on any path from reading it, and `SameSite=Strict` blocks
59/// cross-site CSRF.
60pub const ADMIN_SESSION_COOKIE: &str = "vtc_admin_session";
61
62impl<S: AuthState> FromRequestParts<S> for AuthClaims {
63    type Rejection = AppError;
64
65    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
66        Ok(authenticate(parts, state).await?.0)
67    }
68}
69
70/// Lets a handler accept `Option<AuthClaims>` — "authenticate the caller if
71/// they presented a credential, otherwise carry on anonymously".
72///
73/// Needed by the endpoints that serve two audiences on one route: passkey
74/// *login* is unauthenticated by nature (the ceremony is the authentication),
75/// while passkey *step-up* on the very same task elevates a session the caller
76/// must already hold. Splitting them into two routes would fork a canonical
77/// Trust Task in two.
78///
79/// This is **not** a way to make authentication optional on a protected route:
80/// a caller who presents a credential still has it fully verified, and a bad
81/// one is rejected rather than silently downgraded to anonymous.
82impl<S: AuthState> axum::extract::OptionalFromRequestParts<S> for AuthClaims {
83    type Rejection = AppError;
84
85    async fn from_request_parts(
86        parts: &mut Parts,
87        state: &S,
88    ) -> Result<Option<Self>, Self::Rejection> {
89        let Some(token) = presented_token(parts, state).await else {
90            return Ok(None);
91        };
92        Ok(Some(authenticate_token(&token, state).await?.0))
93    }
94}
95
96/// The shared body of [`AuthClaims::from_request_parts`], additionally handing
97/// back the [`Session`] row it had to load anyway (for the `jti` pin).
98///
99/// [`StepUpAuth`] needs session state the JWT does not carry — how long ago the
100/// second factor was actually confirmed — and re-reading the row per request
101/// would double the store hit on every stepped-up call. Every extractor in this
102/// module funnels through here, so there is exactly one place that decides what
103/// "authenticated" means.
104async fn authenticate<S: AuthState>(
105    parts: &mut Parts,
106    state: &S,
107) -> Result<(AuthClaims, Session), AppError> {
108    let Some(token) = presented_token(parts, state).await else {
109        warn!("auth rejected: no Authorization header and no {ADMIN_SESSION_COOKIE} cookie");
110        return Err(AppError::Unauthorized(
111            "missing or invalid Authorization header".into(),
112        ));
113    };
114    authenticate_token(&token, state).await
115}
116
117/// Pull the caller's JWT off the request, whichever way they presented it.
118///
119/// `Authorization: Bearer <jwt>` first — programmatic clients (cnm-cli, DIDComm
120/// bridges, the `/v1/auth/` flow) all use this. Falls back to the admin session
121/// cookie (Phase 5 M5.2.3) set by `POST /v1/auth/admin-session`, which carries
122/// the same JWT.
123///
124/// `None` means the caller presented **no** credential at all — distinct from
125/// presenting a bad one, which is what lets
126/// [`OptionalFromRequestParts`](axum::extract::OptionalFromRequestParts) treat
127/// an anonymous request as "not authenticated" while still rejecting a forged
128/// or expired token outright.
129async fn presented_token<S: AuthState>(parts: &mut Parts, state: &S) -> Option<String> {
130    let bearer = TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
131        .await
132        .ok()
133        .map(|TypedHeader(auth)| auth.token().to_string());
134    bearer.or_else(|| cookie_token(parts, ADMIN_SESSION_COOKIE))
135}
136
137async fn authenticate_token<S: AuthState>(
138    token: &str,
139    state: &S,
140) -> Result<(AuthClaims, Session), AppError> {
141    {
142        // Decode and validate JWT
143        let jwt_keys = state
144            .jwt_keys()
145            .ok_or_else(|| AppError::Unauthorized("auth not configured".into()))?;
146
147        let claims = jwt_keys.decode(token)?;
148
149        // Verify session exists and is authenticated
150        let session = get_session(state.sessions_ks(), &claims.session_id)
151            .await?
152            .ok_or_else(|| {
153                warn!(session_id = %claims.session_id, "auth rejected: session not found");
154                AppError::Unauthorized("session not found".into())
155            })?;
156
157        if session.state != SessionState::Authenticated {
158            warn!(session_id = %claims.session_id, "auth rejected: session not in authenticated state");
159            return Err(AppError::Unauthorized("session not authenticated".into()));
160        }
161
162        // jti pin: when the session records a `token_id`, only the token whose
163        // `jti` matches it authenticates. Minting a fresh token (login, refresh,
164        // step-up) rotates `token_id`, so every previously-issued access token
165        // for this session is superseded immediately — the mechanism that keeps
166        // a non-rotating session_id revocable. Skipped when `token_id` is unset
167        // (sessions written before this field, or intrinsic-sender sessions that
168        // carry no JWT), preserving their existing behaviour.
169        if let Some(ref pinned) = session.token_id
170            && claims.jti != *pinned
171        {
172            warn!(session_id = %claims.session_id, "auth rejected: token superseded (jti mismatch)");
173            return Err(AppError::Unauthorized("token superseded".into()));
174        }
175
176        let role = Role::parse(&claims.role)?;
177
178        Ok((
179            AuthClaims {
180                did: claims.sub,
181                role,
182                allowed_contexts: claims.contexts,
183                session_id: claims.session_id,
184                access_expires_at: claims.exp,
185                amr: claims.amr,
186                acr: claims.acr,
187            },
188            session,
189        ))
190    }
191}
192
193impl AuthClaims {
194    /// **UNSAFE**: Synthesize a super-admin claim with no wire-level
195    /// verification. Only for **on-host offline CLI** invocations — the
196    /// trust boundary is the OS process, not the network.
197    ///
198    /// Feature-gated behind `cli-synthesis` so this function is physically
199    /// absent from enclave and server-only builds. Any caller compiles
200    /// iff the feature is on; calling this from a route handler is a bug
201    /// that the type system can't catch (the resulting `AuthClaims` is
202    /// indistinguishable from a legitimate one), so the name loudly marks
203    /// the footgun.
204    ///
205    /// The trust model: a process that can execute the VTA binary AND
206    /// read the keystore + seed store is already trusted by the OS to
207    /// act as the VTA itself. Offline CLIs that mutate state (mint keys,
208    /// seal bundles, export admin credentials) pre-date any over-the-
209    /// wire authentication, so wire-level claims can't gate them. The
210    /// caller-supplied `channel` is recorded in the audit log so misuse
211    /// can be traced back to the specific CLI path.
212    ///
213    /// Downstream hardening (tracked as review item 9 follow-up):
214    /// - Require an operator-side credential (env var / local config
215    ///   pointing at a key in the ACL) before synthesizing.
216    /// - Audit-log process identity (`uid`, `pid`, `cwd`) alongside
217    ///   `channel` so a forensic investigator can distinguish
218    ///   operator-intentional runs from lateral-movement abuse.
219    ///
220    /// The sentinel DID format `"cli:<channel>"` (not `did:*`) is
221    /// deliberate — it doesn't round-trip through DID resolution and
222    /// can't be confused with a real caller DID in log correlation.
223    #[cfg(feature = "cli-synthesis")]
224    pub fn unsafe_local_cli_super_admin(channel: &str) -> Self {
225        Self {
226            did: format!("cli:{channel}"),
227            role: Role::Admin,
228            allowed_contexts: Vec::new(),
229            // CLI synthesis bypasses the session store entirely.
230            // The sentinel session_id matches the DID format and
231            // `access_expires_at: 0` makes the synthesized claim
232            // visibly "no real expiry" to any log scraper.
233            session_id: format!("cli:{channel}"),
234            access_expires_at: 0,
235            // CLI synthesis is a process-local trust boundary; the auth
236            // method is the OS user, not a wire factor. Surface `"cli"`
237            // in amr so a downstream auditor distinguishes synthesized
238            // claims from real authenticated sessions.
239            amr: vec!["cli".to_string()],
240            acr: String::new(),
241        }
242    }
243
244    /// This caller's authority to **act**, decoded from `(role,
245    /// allowed_contexts)`.
246    ///
247    /// Use this — or [`has_context_access`](Self::has_context_access), which is
248    /// built on it — rather than inspecting `allowed_contexts` directly. An
249    /// empty list means *unrestricted* for [`Role::Admin`] and *nothing at all*
250    /// for every other role; a call site that tests `is_empty()` without the
251    /// role gets one of those two cases backwards. See [`ActScope`].
252    pub fn act_scope(&self) -> ActScope {
253        act_scope_for(&self.role, &self.allowed_contexts)
254    }
255
256    /// Returns `true` if the caller is an admin whose [`ActScope`] is
257    /// unrestricted.
258    pub fn is_super_admin(&self) -> bool {
259        self.role == Role::Admin && self.act_scope().is_unrestricted()
260    }
261
262    /// Returns `true` if the caller may act in the given context — because
263    /// their [`ActScope`] is unrestricted, or because it names `context_id`
264    /// itself **or an ancestor of it** (folder-level authority: admin of a
265    /// parent context covers the whole subtree).
266    ///
267    /// Ancestry is the segment-aware
268    /// [`is_ancestor_or_self`](crate::context_path::is_ancestor_or_self) — a
269    /// pure, store-free check over the verified JWT's contexts. For today's flat
270    /// (single-segment, childless) contexts this is identical to the previous
271    /// exact match.
272    pub fn has_context_access(&self, context_id: &str) -> bool {
273        self.act_scope().covers(context_id)
274    }
275
276    /// Clone these claims with `extra` contexts merged into `allowed_contexts`.
277    ///
278    /// This is how a **consented per-task delegation** is realized: an approver
279    /// who holds admin in a context authorizes one specific task, and the
280    /// executor runs *that one dispatch* under the requester's identity widened
281    /// to include the delegated context. The widening lives only for the single
282    /// consented, payload-bound, single-use execution — it is never persisted
283    /// onto the session or the JWT, so the agent accrues no standing authority.
284    ///
285    /// Never *widens* a super-admin (empty `allowed_contexts` already means "all
286    /// contexts", so there is nothing to add and replacing the empty list would
287    /// wrongly *narrow* it) and is a no-op when `extra` is empty. Duplicates are
288    /// dropped so repeated delegation can't bloat the list.
289    pub fn with_delegated_contexts(&self, extra: &[String]) -> Self {
290        let mut claims = self.clone();
291        if extra.is_empty() || claims.is_super_admin() {
292            return claims;
293        }
294        for ctx in extra {
295            if !claims.allowed_contexts.iter().any(|c| c == ctx) {
296                claims.allowed_contexts.push(ctx.clone());
297            }
298        }
299        claims
300    }
301
302    /// Realize a **consented grant** for a single dispatch: the approval conferred
303    /// full authority over `extra`, so the requester need hold **no standing
304    /// admin at all**.
305    ///
306    /// Unlike [`with_delegated_contexts`] — which widens context but keeps the
307    /// requester's role — this also lifts the role to [`Role::Admin`], because
308    /// the grant authorizes the exact bound task in full. That is what lets a
309    /// purely unprivileged agent (a Reader that can act nowhere) execute a task an
310    /// approver blessed: the approval *is* the authority. Ephemeral in exactly the
311    /// same way as the context widening — built for one dispatch, never persisted
312    /// to the session, JWT, or ACL — so the agent accrues no standing power.
313    ///
314    /// A no-op when `extra` is empty (nothing was delegated — an ordinary
315    /// same-context, already-authorized execution) and for a super-admin (already
316    /// unrestricted; adding to the empty list would wrongly narrow it).
317    pub fn with_delegated_authority(&self, extra: &[String]) -> Self {
318        let mut claims = self.clone();
319        if extra.is_empty() || claims.is_super_admin() {
320            return claims;
321        }
322        claims.role = Role::Admin;
323        for ctx in extra {
324            if !claims.allowed_contexts.iter().any(|c| c == ctx) {
325                claims.allowed_contexts.push(ctx.clone());
326            }
327        }
328        claims
329    }
330
331    /// Check that the caller has access to the given context.
332    ///
333    /// Admins with an empty `allowed_contexts` list have unrestricted access.
334    pub fn require_context(&self, context_id: &str) -> Result<(), AppError> {
335        if self.has_context_access(context_id) {
336            return Ok(());
337        }
338        Err(AppError::Forbidden(format!(
339            "no access to context: {context_id}"
340        )))
341    }
342
343    /// If the caller has exactly one allowed context, return it.
344    pub fn default_context(&self) -> Option<&str> {
345        if self.allowed_contexts.len() == 1 {
346            Some(&self.allowed_contexts[0])
347        } else {
348            None
349        }
350    }
351
352    /// Require at least Reader role (all roles except Monitor).
353    ///
354    /// Use for read-only endpoints that access business data (keys, contexts, DIDs).
355    /// Monitor can only see metrics and health.
356    pub fn require_read(&self) -> Result<(), AppError> {
357        if self.role == Role::Monitor {
358            return Err(AppError::Forbidden("reader role or higher required".into()));
359        }
360        Ok(())
361    }
362
363    /// Require at least Application role (Admin, Initiator, or Application).
364    ///
365    /// Use for write operations: signing, cache writes, and other actions that
366    /// produce artifacts or modify state.
367    pub fn require_write(&self) -> Result<(), AppError> {
368        if matches!(self.role, Role::Admin | Role::Initiator | Role::Application) {
369            return Ok(());
370        }
371        Err(AppError::Forbidden(
372            "application role or higher required".into(),
373        ))
374    }
375
376    /// Require the caller to have Admin role.
377    pub fn require_admin(&self) -> Result<(), AppError> {
378        if self.role == Role::Admin {
379            return Ok(());
380        }
381        Err(AppError::Forbidden("admin role required".into()))
382    }
383
384    /// Require the caller to have Admin or Initiator role.
385    pub fn require_manage(&self) -> Result<(), AppError> {
386        if self.role == Role::Admin || self.role == Role::Initiator {
387            return Ok(());
388        }
389        Err(AppError::Forbidden(
390            "admin or initiator role required".into(),
391        ))
392    }
393
394    /// Require this caller's session to carry a **live** step-up elevation —
395    /// the same rule [`StepUpAuth`] enforces, for routes where only *some*
396    /// requests need it.
397    ///
398    /// A whole-route extractor can't express "this PATCH needs a fresh second
399    /// factor only when it promotes someone to admin". Both paths run
400    /// `check_fresh_step_up`, so the in-handler gate can't drift from the
401    /// extractor's; the only difference is that this one re-reads the session
402    /// (the extractor already had it in hand).
403    ///
404    /// A missing session is a refusal, not a pass.
405    pub async fn require_fresh_step_up(&self, sessions: &KeyspaceHandle) -> Result<(), AppError> {
406        let session = get_session(sessions, &self.session_id)
407            .await?
408            .ok_or_else(|| {
409                warn!(session_id = %self.session_id, "step-up rejected: session not found");
410                AppError::StepUpRequired("operation requires a recent step-up".into())
411            })?;
412        check_fresh_step_up(self, &session)
413    }
414
415    /// Require the caller to be a super admin (Admin + unrestricted).
416    pub fn require_super_admin(&self) -> Result<(), AppError> {
417        if self.is_super_admin() {
418            return Ok(());
419        }
420        Err(AppError::Forbidden("super admin required".into()))
421    }
422}
423
424/// Extractor that requires the caller to have Admin or Initiator role.
425///
426/// Use on endpoints that manage ACL entries and other management tasks:
427/// ```ignore
428/// async fn handler(auth: ManageAuth, ...) { }
429/// ```
430#[derive(Debug, Clone)]
431pub struct ManageAuth(pub AuthClaims);
432
433impl<S: AuthState> FromRequestParts<S> for ManageAuth {
434    type Rejection = AppError;
435
436    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
437        let claims = AuthClaims::from_request_parts(parts, state).await?;
438
439        match claims.role {
440            Role::Admin | Role::Initiator => Ok(ManageAuth(claims)),
441            _ => {
442                warn!(did = %claims.did, role = %claims.role, "auth rejected: admin or initiator role required");
443                Err(AppError::Forbidden(
444                    "admin or initiator role required".into(),
445                ))
446            }
447        }
448    }
449}
450
451/// Extractor that requires the caller to have Admin role.
452///
453/// Use on endpoints that modify configuration, create/delete keys, etc.:
454/// ```ignore
455/// async fn handler(auth: AdminAuth, ...) { }
456/// ```
457#[derive(Debug, Clone)]
458pub struct AdminAuth(pub AuthClaims);
459
460impl<S: AuthState> FromRequestParts<S> for AdminAuth {
461    type Rejection = AppError;
462
463    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
464        let claims = AuthClaims::from_request_parts(parts, state).await?;
465
466        match claims.role {
467            Role::Admin => Ok(AdminAuth(claims)),
468            _ => {
469                warn!(did = %claims.did, role = %claims.role, "auth rejected: admin role required");
470                Err(AppError::Forbidden("admin role required".into()))
471            }
472        }
473    }
474}
475
476/// Extractor that requires a **freshly** stepped-up session: `acr == "aal2"`
477/// *and* a step-up elevation whose window is still open.
478///
479/// Use on routes that demand a second factor confirmed **for this operation** —
480/// ACL edits, role promotion, key rotation, backup export; anything that lets
481/// an attacker holding a live session pivot to a long-lived foothold.
482///
483/// ```ignore
484/// async fn rotate_keys(auth: StepUpAuth, ...) { /* fresh aal2 enforced */ }
485/// ```
486///
487/// A request that fails either half is rejected with
488/// [`AppError::StepUpRequired`] (403 + body
489/// `{ "error": "step_up_required", "requiredAcr": "aal2" }`). The
490/// wallet uses that signal to trigger a passkey-login or
491/// VTA-approval ceremony — distinct from a generic `forbidden`
492/// it would get from a role gate.
493///
494/// **Why `acr` alone is not enough.** `acr` records the assurance level the
495/// session *reached*, and it stays there for the session's whole life: a
496/// passkey sign-in mints `aal2` up front and the canonical refresh handler
497/// preserves it across every rotation. Gating on `acr` alone would therefore
498/// accept a sign-in from an hour ago and lose the property these routes exist
499/// for — that a stolen session cannot, by itself, authorise the next
500/// promotion. Freshness lives in
501/// [`Session::acr_expires_at`](crate::auth::session::Session::acr_expires_at),
502/// which a step-up ceremony sets to a bounded deadline, so this gate reads the
503/// session row rather than the token.
504///
505/// **Fail closed.** An absent deadline is refused, not waved through: it means
506/// no step-up ceremony ever ran for this session (or the row pre-dates the
507/// field). An unknown elevation time must never read as a recent one.
508#[derive(Debug, Clone)]
509pub struct StepUpAuth(pub AuthClaims);
510
511impl<S: AuthState> FromRequestParts<S> for StepUpAuth {
512    type Rejection = AppError;
513
514    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
515        let (claims, session) = authenticate(parts, state).await?;
516        check_fresh_step_up(&claims, &session)?;
517        Ok(StepUpAuth(claims))
518    }
519}
520
521/// The step-up rule itself, so the extractor and the in-handler check below
522/// can never drift apart.
523fn check_fresh_step_up(claims: &AuthClaims, session: &Session) -> Result<(), AppError> {
524    if claims.acr != "aal2" {
525        warn!(
526            did = %claims.did,
527            acr = %claims.acr,
528            "auth rejected: step-up (aal2) required",
529        );
530        return Err(AppError::StepUpRequired(
531            "operation requires a stepped-up (aal2) session".into(),
532        ));
533    }
534    if !session.elevation_active(now_epoch()) {
535        warn!(
536            did = %claims.did,
537            acr_expires_at = ?session.acr_expires_at,
538            "auth rejected: step-up elevation absent or lapsed",
539        );
540        return Err(AppError::StepUpRequired(
541            "operation requires a recent step-up; re-run the step-up ceremony".into(),
542        ));
543    }
544    Ok(())
545}
546
547/// Extractor that requires the caller to be a super admin (Admin role with
548/// empty `allowed_contexts`).
549///
550/// Use on endpoints that only unrestricted administrators should access,
551/// such as creating/deleting contexts or modifying global configuration:
552/// ```ignore
553/// async fn handler(auth: SuperAdminAuth, ...) { }
554/// ```
555#[derive(Debug, Clone)]
556pub struct SuperAdminAuth(pub AuthClaims);
557
558impl<S: AuthState> FromRequestParts<S> for SuperAdminAuth {
559    type Rejection = AppError;
560
561    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
562        let claims = AuthClaims::from_request_parts(parts, state).await?;
563
564        if !claims.is_super_admin() {
565            warn!(did = %claims.did, "auth rejected: super admin required");
566            return Err(AppError::Forbidden("super admin required".into()));
567        }
568
569        Ok(SuperAdminAuth(claims))
570    }
571}
572
573/// Extractor that requires the caller to have at least Application role
574/// (Admin, Initiator, or Application).
575///
576/// Use on endpoints that perform write operations — signing, cache writes,
577/// and other actions that produce artifacts or modify state:
578/// ```ignore
579/// async fn handler(auth: WriteAuth, ...) { }
580/// ```
581#[derive(Debug, Clone)]
582pub struct WriteAuth(pub AuthClaims);
583
584impl<S: AuthState> FromRequestParts<S> for WriteAuth {
585    type Rejection = AppError;
586
587    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
588        let claims = AuthClaims::from_request_parts(parts, state).await?;
589
590        match claims.role {
591            Role::Admin | Role::Initiator | Role::Application => Ok(WriteAuth(claims)),
592            _ => {
593                warn!(did = %claims.did, role = %claims.role, "auth rejected: application role or higher required");
594                Err(AppError::Forbidden(
595                    "application role or higher required".into(),
596                ))
597            }
598        }
599    }
600}
601
602/// Pull a named cookie value off the request `Cookie` headers.
603/// Returns `None` when the cookie isn't present. Does **not**
604/// percent-decode — cookie values minted by the VTC's admin-session
605/// flow are JWTs (base64url + dots), which are ASCII-safe.
606fn cookie_token(parts: &Parts, name: &str) -> Option<String> {
607    parts
608        .headers
609        .get_all(axum::http::header::COOKIE)
610        .iter()
611        .filter_map(|v| v.to_str().ok())
612        .flat_map(|s| s.split(';'))
613        .map(|s| s.trim())
614        .find_map(|kv| {
615            let (k, v) = kv.split_once('=')?;
616            (k == name).then(|| v.to_string())
617        })
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::auth::jwt::Claims;
624    use crate::auth::session::{now_epoch, store_session};
625    use crate::config::StoreConfig;
626    use crate::store::Store;
627
628    /// Minimal [`AuthState`] so the extractors can be driven end-to-end
629    /// (real JWT, real session row) instead of asserting on their parts.
630    #[derive(Clone)]
631    struct TestState {
632        keys: Arc<JwtKeys>,
633        sessions: KeyspaceHandle,
634    }
635
636    impl AuthState for TestState {
637        fn jwt_keys(&self) -> Option<&Arc<JwtKeys>> {
638            Some(&self.keys)
639        }
640        fn sessions_ks(&self) -> &KeyspaceHandle {
641            &self.sessions
642        }
643    }
644
645    fn test_state() -> (TestState, tempfile::TempDir) {
646        let dir = tempfile::tempdir().expect("tempdir");
647        let store = Store::open(&StoreConfig {
648            data_dir: dir.path().to_path_buf(),
649        })
650        .expect("open store");
651        let state = TestState {
652            keys: Arc::new(JwtKeys::from_ed25519_bytes(&[7u8; 32], "VTA").expect("build jwt keys")),
653            sessions: store.keyspace("sessions").expect("keyspace"),
654        };
655        (state, dir)
656    }
657
658    /// Authenticate `did` at `acr`, with the step-up window set to
659    /// `acr_expires_at`, and return request parts carrying the bearer token.
660    async fn authed_parts(
661        state: &TestState,
662        acr: &str,
663        acr_expires_at: Option<u64>,
664    ) -> axum::http::request::Parts {
665        let did = "did:key:zStepUp";
666        let claims: Claims = state
667            .keys
668            .new_claims(
669                did.to_string(),
670                did.to_string(),
671                "admin".to_string(),
672                Vec::new(),
673                900,
674                false,
675            )
676            .with_aal(vec!["passkey".to_string()], acr);
677        let token = state.keys.encode(&claims).expect("encode jwt");
678
679        let session = Session {
680            session_id: did.to_string(),
681            did: did.to_string(),
682            challenge: String::new(),
683            state: SessionState::Authenticated,
684            created_at: now_epoch(),
685            last_seen: now_epoch(),
686            refresh_token: None,
687            refresh_expires_at: None,
688            tee_attested: false,
689            amr: vec!["passkey".to_string()],
690            acr: acr.to_string(),
691            acr_expires_at,
692            token_id: None,
693            session_pubkey_b58btc: None,
694        };
695        store_session(&state.sessions, &session)
696            .await
697            .expect("store session");
698
699        let (parts, _) = axum::http::Request::builder()
700            .uri("/anything")
701            .header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
702            .body(())
703            .expect("build request")
704            .into_parts();
705        parts
706    }
707
708    #[tokio::test]
709    async fn step_up_accepts_a_live_elevation() {
710        let (state, _dir) = test_state();
711        let mut parts = authed_parts(&state, "aal2", Some(now_epoch() + 900)).await;
712        let auth = StepUpAuth::from_request_parts(&mut parts, &state)
713            .await
714            .expect("a live step-up window must satisfy the gate");
715        assert_eq!(auth.0.acr, "aal2");
716    }
717
718    #[tokio::test]
719    async fn step_up_refuses_an_aal2_session_that_was_never_stepped_up() {
720        // The load-bearing case: a passkey sign-in is `aal2` from its first
721        // request and carries no elevation window. Gating on `acr` alone would
722        // accept it hours later — exactly the "a stolen session cannot persist"
723        // property these routes exist to keep.
724        let (state, _dir) = test_state();
725        let mut parts = authed_parts(&state, "aal2", None).await;
726        let err = StepUpAuth::from_request_parts(&mut parts, &state)
727            .await
728            .expect_err("an absent elevation window must fail closed");
729        assert!(
730            matches!(err, AppError::StepUpRequired(_)),
731            "expected StepUpRequired, got {err:?}"
732        );
733    }
734
735    #[tokio::test]
736    async fn step_up_refuses_a_lapsed_elevation() {
737        let (state, _dir) = test_state();
738        let mut parts = authed_parts(&state, "aal2", Some(now_epoch().saturating_sub(1))).await;
739        let err = StepUpAuth::from_request_parts(&mut parts, &state)
740            .await
741            .expect_err("a lapsed step-up window must be refused");
742        assert!(
743            matches!(err, AppError::StepUpRequired(_)),
744            "expected StepUpRequired, got {err:?}"
745        );
746    }
747
748    #[tokio::test]
749    async fn step_up_refuses_aal1_even_with_a_live_window() {
750        // Both halves are required: a live window on a session that never
751        // reached aal2 is not a second factor.
752        let (state, _dir) = test_state();
753        let mut parts = authed_parts(&state, "aal1", Some(now_epoch() + 900)).await;
754        let err = StepUpAuth::from_request_parts(&mut parts, &state)
755            .await
756            .expect_err("aal1 must be refused regardless of the window");
757        assert!(
758            matches!(err, AppError::StepUpRequired(_)),
759            "expected StepUpRequired, got {err:?}"
760        );
761    }
762
763    #[tokio::test]
764    async fn ordinary_auth_is_unaffected_by_the_freshness_rule() {
765        // `AuthClaims` (and every role gate built on it) must keep accepting a
766        // session with no elevation window — the gate is opt-in per route.
767        let (state, _dir) = test_state();
768        let mut parts = authed_parts(&state, "aal2", None).await;
769        let claims = AuthClaims::from_request_parts(&mut parts, &state)
770            .await
771            .expect("plain authentication must not require a step-up");
772        assert_eq!(claims.did, "did:key:zStepUp");
773        assert_eq!(claims.acr, "aal2");
774    }
775
776    #[test]
777    fn has_context_access_grants_the_subtree_to_a_parent_admin() {
778        // A context admin scoped to `acme/eng` (not super-admin — the list is
779        // non-empty), so ancestry applies.
780        let claims = AuthClaims {
781            role: Role::Admin,
782            allowed_contexts: vec!["acme/eng".into()],
783            ..Default::default()
784        };
785        assert!(!claims.is_super_admin());
786
787        // Self + every descendant.
788        assert!(claims.has_context_access("acme/eng"));
789        assert!(claims.has_context_access("acme/eng/team-a"));
790        assert!(claims.has_context_access("acme/eng/team-a/squad-1"));
791
792        // NOT the parent, a sibling, or a prefix-confusion look-alike.
793        assert!(!claims.has_context_access("acme"));
794        assert!(!claims.has_context_access("acme/ops"));
795        assert!(!claims.has_context_access("acme/engineering"));
796
797        assert!(claims.require_context("acme/eng/team-a").is_ok());
798        assert!(claims.require_context("acme/ops").is_err());
799    }
800
801    #[test]
802    fn with_delegated_contexts_widens_a_scoped_admin_for_one_call() {
803        let base = AuthClaims {
804            role: Role::Admin,
805            allowed_contexts: vec!["ctx-a".into()],
806            ..Default::default()
807        };
808        // Before: no access to the delegated context.
809        assert!(base.require_context("openvtc").is_err());
810
811        let widened = base.with_delegated_contexts(&["openvtc".into()]);
812        assert!(widened.require_context("openvtc").is_ok());
813        assert!(
814            widened.require_context("ctx-a").is_ok(),
815            "keeps its own context"
816        );
817        // The delegation is a fresh value — the caller's own claims are untouched.
818        assert!(base.require_context("openvtc").is_err());
819    }
820
821    #[test]
822    fn with_delegated_contexts_is_a_noop_for_empty_or_super_admin() {
823        let scoped = AuthClaims {
824            role: Role::Admin,
825            allowed_contexts: vec!["ctx-a".into()],
826            ..Default::default()
827        };
828        // Empty delegation changes nothing.
829        assert_eq!(
830            scoped.with_delegated_contexts(&[]).allowed_contexts,
831            scoped.allowed_contexts
832        );
833        // A super-admin (empty list = all contexts) must never be narrowed to a
834        // scoped list by a delegation.
835        let sa = AuthClaims {
836            role: Role::Admin,
837            ..Default::default()
838        };
839        assert!(sa.is_super_admin());
840        let after = sa.with_delegated_contexts(&["openvtc".into()]);
841        assert!(after.is_super_admin(), "super-admin stays unrestricted");
842        assert!(after.allowed_contexts.is_empty());
843    }
844
845    #[test]
846    fn with_delegated_authority_lifts_a_non_admin_for_one_dispatch() {
847        // Fix 2: a purely unprivileged agent (Reader, acts nowhere) executes a
848        // task an approver blessed — the grant confers both admin and context.
849        let reader = AuthClaims {
850            role: Role::Reader,
851            allowed_contexts: vec![],
852            ..Default::default()
853        };
854        assert!(reader.require_admin().is_err());
855        assert!(!reader.has_context_access("openvtc"));
856
857        let widened = reader.with_delegated_authority(&["openvtc".into()]);
858        assert!(widened.require_admin().is_ok(), "grant confers admin");
859        assert!(
860            widened.has_context_access("openvtc"),
861            "grant confers the context"
862        );
863
864        // The original is untouched — no standing elevation persists.
865        assert!(reader.require_admin().is_err());
866        assert!(!reader.has_context_access("openvtc"));
867    }
868
869    #[test]
870    fn with_delegated_authority_is_a_noop_for_empty_or_super_admin() {
871        let reader = AuthClaims {
872            role: Role::Reader,
873            allowed_contexts: vec![],
874            ..Default::default()
875        };
876        // Empty delegation changes nothing (an ordinary self-authorized execution).
877        let after = reader.with_delegated_authority(&[]);
878        assert_eq!(after.role, Role::Reader);
879        assert!(after.allowed_contexts.is_empty());
880
881        // A super-admin is already unrestricted; never narrow it to a scoped list.
882        let sa = AuthClaims {
883            role: Role::Admin,
884            ..Default::default()
885        };
886        assert!(sa.is_super_admin());
887        let after = sa.with_delegated_authority(&["openvtc".into()]);
888        assert!(after.is_super_admin(), "super-admin stays unrestricted");
889    }
890
891    #[test]
892    fn with_delegated_contexts_dedups() {
893        let base = AuthClaims {
894            role: Role::Admin,
895            allowed_contexts: vec!["ctx-a".into()],
896            ..Default::default()
897        };
898        let widened = base.with_delegated_contexts(&["ctx-a".into(), "openvtc".into()]);
899        assert_eq!(widened.allowed_contexts, vec!["ctx-a", "openvtc"]);
900    }
901
902    #[test]
903    fn flat_context_grant_is_exact_match_only() {
904        // A single-segment grant with no sub-contexts behaves exactly as before.
905        let claims = AuthClaims {
906            role: Role::Reader,
907            allowed_contexts: vec!["prod-mediator".into()],
908            ..Default::default()
909        };
910        assert!(claims.has_context_access("prod-mediator"));
911        assert!(!claims.has_context_access("prod-mediator-2"));
912        assert!(!claims.has_context_access("other"));
913    }
914
915    #[cfg(feature = "cli-synthesis")]
916    #[test]
917    fn local_cli_synthesizes_super_admin_with_channel_sentinel() {
918        let claims = AuthClaims::unsafe_local_cli_super_admin("provision-integration");
919        assert_eq!(claims.did, "cli:provision-integration");
920        assert_eq!(claims.role, Role::Admin);
921        assert!(claims.allowed_contexts.is_empty());
922        assert!(claims.is_super_admin());
923    }
924
925    #[cfg(feature = "cli-synthesis")]
926    #[test]
927    fn local_cli_grants_any_context_access() {
928        let claims = AuthClaims::unsafe_local_cli_super_admin("keys-bundle");
929        // Super-admin has access to every context — enforced elsewhere
930        // but assert it explicitly here so a future refactor that
931        // breaks the invariant gets caught.
932        assert!(claims.has_context_access("any-context"));
933        assert!(claims.has_context_access("another"));
934        claims
935            .require_context("prod-mediator")
936            .expect("super-admin passes require_context");
937    }
938
939    #[cfg(feature = "cli-synthesis")]
940    #[test]
941    fn local_cli_did_sentinel_cannot_be_confused_with_real_did() {
942        // The `cli:<channel>` format must not round-trip as a
943        // `did:*` URI — otherwise audit-log correlation would muddle
944        // CLI-synthesized claims with real caller identities.
945        let claims = AuthClaims::unsafe_local_cli_super_admin("context-reprovision");
946        assert!(!claims.did.starts_with("did:"));
947        assert!(claims.did.starts_with("cli:"));
948    }
949
950    #[cfg(feature = "cli-synthesis")]
951    #[test]
952    fn local_cli_channel_embedded_in_did() {
953        // Audit-log grep'ability: each synthesis records its `channel`
954        // distinctly so forensic investigation can attribute CLI
955        // actions to the specific code path that ran them.
956        let a = AuthClaims::unsafe_local_cli_super_admin("provision-integration");
957        let b = AuthClaims::unsafe_local_cli_super_admin("keys-bundle");
958        assert_ne!(a.did, b.did);
959    }
960}