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}