Skip to main content

cratefield_module_telegram/
step_up.rs

1//! The step-up seam: proof that a request carries a **fresh** passkey
2//! ceremony, beyond being signed in at all.
3//!
4//! Being signed in is not the same as being present. A session token
5//! lives for hours on whatever device minted it; a consent button that
6//! moves value must not take the token's word for it. The [`StepUp`]
7//! trait is the module's seam for the difference — the web app's
8//! `/confirm` route refuses to approve a value-moving action unless a
9//! configured `StepUp` says this very request proved one. With none
10//! configured the route answers `403` for everyone: fail closed, never
11//! open.
12//!
13//! # What an implementation must prove
14//!
15//! A passkey assertion — a WebAuthn ceremony — completed **for this
16//! subject** within this request, or within a freshness window the
17//! implementation names. Not merely a passkey-born session:
18//!
19//! the auth service stores the login's methods in the session's `amr`
20//! and copies them into **every** access token it mints for that
21//! session, stamping each mint with a fresh `iat` — the refresh grant
22//! included (`cratefield-auth-core`'s `minted_response` reads `amr` off
23//! the session row; `mint_access_token` takes `iat` from the clock). A
24//! token refreshed hours after a passkey login therefore carries
25//! `amr: ["…", "passkey"]` and an `iat` seconds old while proving
26//! nothing about the last minutes. No claim on an access token
27//! (`amr`, `iat`, `sid` — there is no `auth_time`) distinguishes a
28//! fresh ceremony from a passkey-born session, so **a verifier that
29//! reads them off the bearer token is unsound and must not be
30//! written**. The proof has to come from a ceremony the implementation
31//! itself observes: a WebAuthn assertion in the request body checked
32//! against the subject's registered credentials, or a service-side
33//! record of one of equal strength, both bound to the subject.
34
35use async_trait::async_trait;
36use http::HeaderMap;
37
38/// Proves, or refuses to prove, that a request carries a fresh passkey
39/// ceremony for `subject`.
40///
41/// Implemented by the venture and injected with `Telegram::step_up`.
42/// `headers` are the confirm request's own headers — the credentials the
43/// request itself presented; `subject` is the account the action belongs
44/// to, which the proof must be about — a ceremony by a colleague is not
45/// a ceremony by you.
46///
47/// The contract, in one sentence: return `true` only when you watched a
48/// passkey assertion for `subject` complete within this request or
49/// within your own freshness window; every doubt returns `false`, and
50/// the route answers `403` without touching the action.
51#[async_trait]
52pub trait StepUp: Send + Sync {
53    /// `true` only when this request proves a fresh passkey ceremony for
54    /// `subject`. Never asked twice for one action: the conditional
55    /// update behind it is single-use.
56    async fn passkey_verified(&self, headers: &HeaderMap, subject: &str) -> bool;
57}