Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-ui-server/src/auth.rs

//! Passkey login, sessions, and the gate in front of everything else — ported
//! from gunnar-ui-server (`auth/mod.rs`, `auth/middleware.rs`, `auth/routes.rs`)
//! with the invite machinery left out. Every mutation under `/api/` is money
//! or a repair, and it — like enrolling a passkey — wants a STEP-UP: an admin
//! passkey touched within [`STEP_UP_SECS`] ([`needs_step_up`], [`strong`]).
//!
//! The ceremony is hand-written against the WebAuthn spec: ES256 over P-256
//! only, attestation deliberately unverified. Sessions are stateless: a
//! `user|role|exp|auth_time|uv|epoch` payload MAC'd with a persisted key;
//! bumping the epoch revokes every cookie at once, inside [`Webauthn::verify_cookie`].

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
use base64::Engine as _;
use http::{HeaderMap, Method, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::app::AppState;
use crate::role::Role;
use crate::state::StateStore;

const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
pub const SESSION_COOKIE: &str = "monetize_ui_session";
pub const SESSION_TTL_SECS: i64 = 12 * 3600;
const CHALLENGE_TTL: Duration = Duration::from_secs(300);
/// `login/start` is unauthenticated; at the cap the oldest ceremony is dropped.
const MAX_PENDING: usize = 1024;
pub const AUDIT_TARGET: &str = "monetize_ui::audit";

pub fn now_secs() -> i64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or_default()
}

/// **STEP-UP** (5.4 §3b): how old a passkey touch may be and still move
/// money, press a repair button or enrol a key (5 min). A session lives
/// [`SESSION_TTL_SECS`] for reading; acting needs a fresh touch.
pub const STEP_UP_SECS: i64 = 300;

/// The `code` of a step-up refusal: the console answers it with a passkey
/// touch and the same request again, never with "your role does not allow".
pub const STEP_UP_CODE: &str = "stepup";

/// Pure: is a session with this role, `uv` flag and `auth_time` strong at
/// `now_secs` — an ADMIN passkey, user-verified, touched within
/// [`STEP_UP_SECS`]? An `auth_time` in the future is wrong, not fresh.
pub fn strong(role: Role, uv: bool, auth_time: i64, now_secs: i64) -> bool {
    let age = now_secs - auth_time;
    role == Role::Admin && uv && (0..=STEP_UP_SECS).contains(&age)
}

/// **What wants a fresh touch, decided in ONE place**: every mutation under
/// `/api/` (all of them money or a repair — see [`role_floor`]) and enrolling
/// a passkey, which would otherwise let a stolen session outlive its theft.
pub fn needs_step_up(method: &Method, path: &str) -> bool {
    (!matches!(*method, Method::GET | Method::HEAD) && path.starts_with("/api/")) || path.starts_with("/auth/passkey/register/")
}

/// Has this caller stepped up? A passkey session is judged by [`strong`];
/// the break-glass bearer is a token (the machine door) and stays one.
pub fn stepped_up(caller: &Caller, now_secs: i64) -> bool {
    match caller {
        Caller::Operator(s) => strong(s.role, s.uv, s.auth_time, now_secs),
        Caller::AdminToken => true,
    }
}

/// The 403 a weak session gets, as JSON the console can tell apart.
pub fn step_up_refusal() -> Response {
    let message = format!("this moves money or keys: touch your admin passkey again (user-verified, within {} min) and it goes through", STEP_UP_SECS / 60);
    (StatusCode::FORBIDDEN, Json(json!({ "code": STEP_UP_CODE, "message": message }))).into_response()
}

fn rand32() -> Vec<u8> {
    let mut b = [0u8; 32];
    b[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
    b[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
    b.to_vec()
}

/// Where the relying party lives, as the browser sees it.
#[derive(Debug, Clone)]
pub struct RelyingParty {
    /// A registrable domain, never an IP literal.
    pub rp_id: String,
    /// The exact browser origin, scheme and port included.
    pub origin: String,
    pub rp_name: String,
}

impl RelyingParty {
    /// `MONETIZE_UI_WEBAUTHN_RP_ID` unset ⇒ `None` ⇒ the passkey surface is off.
    pub fn from_env() -> Result<Option<RelyingParty>, String> {
        let env = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty());
        let Some(rp_id) = env("MONETIZE_UI_WEBAUTHN_RP_ID") else { return Ok(None) };
        if rp_id.parse::<std::net::IpAddr>().is_ok() {
            return Err(format!("MONETIZE_UI_WEBAUTHN_RP_ID is {rp_id:?}; WebAuthn needs a domain — use \"localhost\" in development"));
        }
        let explicit = env("MONETIZE_UI_WEBAUTHN_ORIGIN");
        if rp_id == "localhost" && explicit.is_none() {
            return Err("MONETIZE_UI_WEBAUTHN_RP_ID is \"localhost\" but MONETIZE_UI_WEBAUTHN_ORIGIN is unset; set it to http://localhost:8091".into());
        }
        let origin = explicit.unwrap_or_else(|| format!("https://{rp_id}")).trim_end_matches('/').to_owned();
        Ok(Some(RelyingParty { origin, rp_id, rp_name: env("MONETIZE_UI_WEBAUTHN_RP_NAME").unwrap_or_else(|| "monetize".to_owned()) }))
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Purpose { Register, Login }

/// A ceremony in flight; a registration carries the operator handle it
/// enrols for, minted by the console, never sent back by the browser.
struct Pending { challenge_b64: String, purpose: Purpose, at: Instant, operator: Option<String> }

/// A registered passkey.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredCred {
    /// base64url credential id.
    pub id: String,
    /// ES256 public key coordinates, 32 bytes each.
    pub x: Vec<u8>,
    pub y: Vec<u8>,
    pub sign_count: u32,
    /// The device name, for listing and revoking — never a login.
    pub label: String,
    pub created: i64,
    /// The OPERATOR this key signs in as: an opaque handle the console mints
    /// at enrolment (`op-…`), whose bytes are the credential's WebAuthn user
    /// handle. No account name exists (owner 2026-10-11).
    pub user: String,
    pub role: Role,
    pub last_used: Option<i64>,
    pub disabled: bool,
}

/// A parsed, MAC-verified, unexpired session.
#[derive(Debug, Clone)]
pub struct Session {
    pub user: String,
    pub role: Role,
    pub auth_time: i64,
    pub uv: bool,
    epoch: u64,
}

pub struct Webauthn {
    rp: RelyingParty,
    /// The SHARED relying party: what `nornir_auth_webauthn::pure` checks every
    /// ceremony against, built and validated once in [`Webauthn::new`].
    pure_rp: nornir_auth_webauthn::pure::PureRelyingParty,
    session_key: [u8; 32],
    pending: Mutex<HashMap<String, Pending>>,
    state: StateStore,
}

impl Webauthn {
    pub fn new(rp: RelyingParty, state: StateStore) -> Result<Webauthn, String> {
        let key: [u8; 32] = state.get_or_init("session_key", rand32)?.try_into().map_err(|_| "the stored session key is not 32 bytes".to_owned())?;
        // An IP-literal rp_id, or an origin that is not the rp_id or a subdomain
        // of it, is a console nobody can ever enrol a key against. Refused at
        // BOOT, by name, rather than at the first login.
        let pure_rp = nornir_auth_webauthn::pure::PureRelyingParty::new(&rp.rp_id, &rp.origin, &rp.rp_name)
            .map_err(|e| format!("the relying party this console binds passkeys to is unusable: {e}"))?;
        Ok(Webauthn { rp, pure_rp, session_key: key, pending: Mutex::new(HashMap::new()), state })
    }

    pub fn origin(&self) -> &str {
        &self.rp.origin
    }

    fn current_epoch(&self) -> u64 {
        self.state.get("session_epoch").ok().flatten().and_then(|b| b.try_into().ok()).map(u64::from_le_bytes).unwrap_or(0)
    }

    /// Invalidate every outstanding session cookie.
    pub fn bump_epoch(&self) -> Result<u64, String> {
        let next = self.current_epoch().wrapping_add(1);
        self.state.put("session_epoch", &next.to_le_bytes())?;
        Ok(next)
    }

    fn new_challenge(&self, purpose: Purpose, operator: Option<String>) -> (String, String) {
        let challenge_b64 = B64.encode(rand32());
        let id = uuid::Uuid::new_v4().to_string();
        let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
        pending.retain(|_, p| p.at.elapsed() < CHALLENGE_TTL);
        while pending.len() >= MAX_PENDING {
            let Some(oldest) = pending.iter().min_by_key(|(_, p)| p.at).map(|(k, _)| k.clone()) else { break };
            pending.remove(&oldest);
        }
        pending.insert(id.clone(), Pending { challenge_b64: challenge_b64.clone(), purpose, at: Instant::now(), operator });
        (id, challenge_b64)
    }

    /// Single-use, unexpired, and for this purpose.
    fn take_challenge(&self, id: &str, want: Purpose) -> Option<Pending> {
        let p = self.pending.lock().unwrap_or_else(|e| e.into_inner()).remove(id)?;
        (p.at.elapsed() < CHALLENGE_TTL && p.purpose == want).then_some(p)
    }

    fn parse_session(&self, cookie: &str) -> Option<Session> {
        let (payload_b64, mac_b64) = cookie.split_once('.')?;
        let payload = B64.decode(payload_b64).ok()?;
        let mac: [u8; 32] = B64.decode(mac_b64).ok()?.try_into().ok()?;
        // blake3::Hash compares in constant time.
        if blake3::Hash::from_bytes(mac) != blake3::keyed_hash(&self.session_key, &payload) {
            return None;
        }
        let text = std::str::from_utf8(&payload).ok()?;
        let mut f = text.splitn(6, '|');
        let user = f.next()?.to_owned();
        let role: Role = f.next()?.parse().ok()?;
        let expires_at: i64 = f.next()?.parse().ok()?;
        let auth_time: i64 = f.next()?.parse().ok()?;
        let uv = f.next()? == "1";
        let epoch: u64 = f.next()?.parse().ok()?;
        (expires_at > now_secs()).then_some(Session { user, role, auth_time, uv, epoch })
    }

    /// MAC, expiry, and the revocation epoch — every authorisation path goes
    /// through here, so revocation needs no work at any call site.
    pub fn verify_cookie(&self, cookie: &str) -> Option<Session> {
        let s = self.parse_session(cookie)?;
        (s.epoch == self.current_epoch()).then_some(s)
    }

    /// Mint a session cookie VALUE for `user` as `role`. Public so an offline
    /// enrolment or a test rig can hold a session without an authenticator.
    pub fn mint_session(&self, user: &str, role: Role, uv: bool) -> String {
        self.mint_session_at(user, role, uv, now_secs())
    }

    /// [`Webauthn::mint_session`] for a touch made at `auth_time`; the
    /// session still expires [`SESSION_TTL_SECS`] from now.
    pub fn mint_session_at(&self, user: &str, role: Role, uv: bool, auth_time: i64) -> String {
        let now = now_secs();
        let payload = format!("{user}|{}|{}|{auth_time}|{}|{}", role.as_token(), now + SESSION_TTL_SECS, uv as u8, self.current_epoch());
        let mac = blake3::keyed_hash(&self.session_key, payload.as_bytes());
        format!("{}.{}", B64.encode(payload.as_bytes()), B64.encode(mac.as_bytes()))
    }

    fn load_creds(&self) -> Vec<StoredCred> {
        self.state.get("passkeys").ok().flatten().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
    }

    fn save_creds(&self, creds: &[StoredCred]) -> Result<(), String> {
        self.state.put("passkeys", &serde_json::to_vec(creds).map_err(|e| e.to_string())?)
    }

    pub fn credentials(&self) -> Vec<StoredCred> {
        self.load_creds()
    }

    /// Store a credential enrolled outside the browser ceremony (a key whose
    /// public half arrived by other means). Refuses a duplicate id.
    pub fn adopt(&self, cred: StoredCred) -> Result<(), String> {
        let mut creds = self.load_creds();
        if creds.iter().any(|c| c.id == cred.id) {
            return Err("this key is already registered".into());
        }
        creds.push(cred);
        self.save_creds(&creds)
    }

    /// **Break-glass recovery** (the operator lost the passkey device): every
    /// passkey DISABLED (kept: the record says who held what), every session
    /// revoked, so the break-glass token enrols the first passkey again.
    /// Answers how many were disabled. The door that asks it is the operator's
    /// alone (monetize-admin's mutual-TLS door); nothing on the web face does.
    pub fn reset_passkeys(&self) -> Result<usize, String> {
        let mut creds = self.load_creds();
        let n = creds.iter().filter(|c| !c.disabled).count();
        for c in creds.iter_mut() {
            c.disabled = true;
        }
        self.save_creds(&creds)?;
        self.bump_epoch()?;
        Ok(n)
    }

    /// What to call an operator on screen: the label of its oldest enabled
    /// key (its handle is opaque), `None` when it holds none.
    pub fn operator_label(&self, operator: &str) -> Option<String> {
        self.load_creds().into_iter().filter(|c| !c.disabled && c.user == operator).min_by_key(|c| c.created).map(|c| c.label)
    }

    pub fn enabled_admin_count(&self) -> usize {
        self.load_creds().iter().filter(|c| !c.disabled && c.role.allows(Role::Admin)).count()
    }

    /// The break-glass token solves exactly one problem — the first passkey,
    /// when no admin exists to approve one — so the moment an enabled admin
    /// passkey exists it is spent history.
    pub fn admin_token_usable(&self) -> bool {
        self.enabled_admin_count() == 0
    }

    // ── The ceremonies ─────────────────────────────────────────────────

    /// Options for `navigator.credentials.create` — **no account name**
    /// (owner 2026-10-11: "creepy, take that away, we don't have any legacy").
    ///
    /// `operator` is the caller's own handle when an operator's session adds a
    /// key (a spare joins its operator, and that operator's keys are excluded
    /// so one authenticator does not hold two of them); `None` — the
    /// break-glass token's first admin — mints a fresh handle. `label` is the
    /// device name the authenticator's picker shows.
    ///
    /// `residentKey: required` + `userVerification: required`: the key keeps
    /// the passkey and its user handle itself, so a login offers no credential
    /// id and the handle says who signed in. `credProps` lets
    /// [`Webauthn::register_finish`] refuse a key that kept nothing.
    pub fn register_options(&self, operator: Option<&str>, label: &str) -> Result<Value, String> {
        let operator = match operator.map(str::trim).filter(|o| !o.is_empty()) {
            Some(o) => o.to_owned(),
            None => format!("op-{}", B64.encode(&rand32()[..16])),
        };
        let label = label.trim();
        let name = if label.is_empty() { format!("{} operator", self.rp.rp_name) } else { label.to_owned() };
        let exclude: Vec<Value> = self.load_creds().iter().filter(|c| c.user == operator).map(|c| json!({ "type": "public-key", "id": c.id })).collect();
        let (ceremony_id, challenge) = self.new_challenge(Purpose::Register, Some(operator.clone()));
        Ok(json!({
            "ceremony_id": ceremony_id,
            "publicKey": {
                "rp": { "id": self.rp.rp_id, "name": self.rp.rp_name },
                "user": { "id": B64.encode(operator.as_bytes()), "name": name, "displayName": name },
                "challenge": challenge,
                "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }],
                "authenticatorSelection": { "residentKey": "required", "requireResidentKey": true, "userVerification": "required" },
                "extensions": { "credProps": true },
                "timeout": 120000,
                "attestation": "none",
                "excludeCredentials": exclude,
            }
        }))
    }

    /// Verify a registration and store the credential for the operator the
    /// ceremony was started for. A browser that reports the credential as
    /// NOT discoverable (`credProps.rk == false`) is refused: such a key could
    /// never sign in here.
    pub fn register_finish(&self, body: &Value) -> Result<StoredCred, String> {
        let ceremony_id = str_field(body, "ceremony_id")?;
        let pending = self.take_challenge(ceremony_id, Purpose::Register).ok_or("this registration ceremony is unknown or has expired")?;
        let operator = pending.operator.ok_or("this registration ceremony names no operator")?;
        if body.pointer("/clientExtensionResults/credProps/rk").and_then(Value::as_bool) == Some(false) {
            return Err("this key did not keep a discoverable passkey (it may be full) — sign-in here needs one".into());
        }
        let response = body.get("response").ok_or("the body has no response")?;
        let client_data = b64_field(response, "clientDataJSON")?;
        let attestation = b64_field(response, "attestationObject")?;
        let parsed = nornir_auth_webauthn::pure::verify_registration(&self.pure_rp, &attestation, &client_data, &pending.challenge_b64, true)
            .map_err(|e| e.to_string())?;
        let role = match body.get("role").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {
            Some(r) => r.parse()?,
            None => Role::Admin,
        };
        let cred = StoredCred {
            id: parsed.id,
            x: parsed.x,
            y: parsed.y,
            sign_count: parsed.sign_count,
            label: body.get("label").and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()).unwrap_or("passkey").to_owned(),
            created: now_secs(),
            user: operator,
            role,
            last_used: None,
            disabled: false,
        };
        self.adopt(cred.clone())?;
        Ok(cred)
    }

    /// Options for `navigator.credentials.get`: **one button, no name** — an
    /// empty `allowCredentials` (the authenticator offers the discoverable
    /// passkey it keeps; nobody's credential id goes to a stranger) and user
    /// verification required.
    pub fn login_options(&self) -> Result<Value, String> {
        if self.load_creds().iter().all(|c| c.disabled) {
            return Err("no passkey is registered yet".into());
        }
        let (ceremony_id, challenge) = self.new_challenge(Purpose::Login, None);
        Ok(json!({
            "ceremony_id": ceremony_id,
            "publicKey": { "challenge": challenge, "rpId": self.rp.rp_id, "timeout": 120000, "userVerification": "required", "allowCredentials": [] }
        }))
    }

    /// Verify an assertion and mint a session. The credential is found by its
    /// id and must answer with ITS operator's user handle, user-verified.
    /// Returns `(cookie value, operator, role, key label)`.
    pub fn login_finish(&self, body: &Value) -> Result<(String, String, Role, String), String> {
        let ceremony_id = str_field(body, "ceremony_id")?;
        let challenge = self.take_challenge(ceremony_id, Purpose::Login).ok_or("this login ceremony is unknown or has expired")?.challenge_b64;
        let id = str_field(body, "id")?;
        let response = body.get("response").ok_or("the body has no response")?;
        let client_data = b64_field(response, "clientDataJSON")?;
        let auth_data = b64_field(response, "authenticatorData")?;
        let signature = b64_field(response, "signature")?;

        let mut creds = self.load_creds();
        let at = creds.iter().position(|c| c.id == id).ok_or("this credential is not registered here")?;
        if creds[at].disabled {
            return Err("this credential has been revoked".into());
        }
        // The user handle a discoverable credential answers with IS the
        // operator. None (a key enrolled before 2026-10-11 under a name, which
        // kept nothing) or another's: refused — enrol the key again.
        let handle = match response.get("userHandle").and_then(Value::as_str).filter(|h| !h.is_empty()) {
            Some(h) => B64.decode(h).map_err(|_| "userHandle is not base64url".to_owned())?,
            None => return Err("this key answered with no user handle — it holds no discoverable passkey for this console; enrol it again".into()),
        };
        if handle != creds[at].user.as_bytes() {
            return Err("this key's user handle is not the operator it was enrolled for".into());
        }
        // ONE verifier: ceremony type, challenge, origin, rpIdHash,
        // user-presence, the ES256 signature over authData || SHA256(clientData),
        // and the cloned-credential counter rule with zero exempt on both sides.
        let stored = nornir_auth_webauthn::pure::RegisteredCredential {
            id: creds[at].id.clone(),
            x: creds[at].x.clone(),
            y: creds[at].y.clone(),
            sign_count: creds[at].sign_count,
        };
        let verified = nornir_auth_webauthn::pure::verify_assertion(&self.pure_rp, &stored, &client_data, &auth_data, &signature, &challenge, true)
            .map_err(|e| e.to_string())?;
        creds[at].sign_count = verified.sign_count;
        creds[at].last_used = Some(now_secs());
        let (user, role, label) = (creds[at].user.clone(), creds[at].role, creds[at].label.clone());
        let uv = verified.user_verified;
        self.save_creds(&creds)?;
        Ok((self.mint_session(&user, role, uv), user, role, label))
    }
}

fn str_field<'a>(v: &'a Value, key: &str) -> Result<&'a str, String> {
    v.get(key).and_then(|v| v.as_str()).ok_or_else(|| format!("the body has no {key}"))
}

fn b64_field(v: &Value, key: &str) -> Result<Vec<u8>, String> {
    B64.decode(str_field(v, key)?).map_err(|_| format!("{key} is not base64url"))
}

/// `Secure` holds even in development: browsers treat `http://localhost` as trustworthy.
pub fn session_cookie_str(value: &str, max_age: i64) -> String {
    format!("{SESSION_COOKIE}={value}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={max_age}")
}

/// **Every `monetize_ui_session` value the request carries**, across EVERY `Cookie` header field
/// (an HTTP/2 browser may split its cookies into several fields, RFC 9113 §8.2.3) and every pair:
/// the first field alone missed a session cookie that rode in a later one, and an older cookie of
/// the same name (another path or domain) came first and shadowed the good one.
pub fn session_cookies(headers: &HeaderMap) -> Vec<String> {
    headers
        .get_all(http::header::COOKIE)
        .iter()
        .filter_map(|v| v.to_str().ok())
        .flat_map(|v| v.split(';'))
        .filter_map(|pair| pair.trim().split_once('='))
        .filter(|(name, _)| *name == SESSION_COOKIE)
        .map(|(_, v)| v.to_owned())
        .collect()
}

/// The first session that VERIFIES among [`session_cookies`].
pub fn session_of(webauthn: &Webauthn, headers: &HeaderMap) -> Option<Session> {
    session_cookies(headers).iter().find_map(|c| webauthn.verify_cookie(c))
}

pub fn session_cookie(headers: &HeaderMap) -> Option<String> {
    session_cookies(headers).into_iter().next()
}

// ── Ceremony crypto ────────────────────────────────────
//
// GONE. check_client_data, check_auth_data, parse_registration and
// verify_signature stood here and stood identically in holger-ui and in
// gunnar-ui. They are now one implementation, `nornir_auth_webauthn::pure`,
// published from edda and linked by all three consoles.

// ── The gate ──────────────────────────────────────────────────────────

/// Paths reachable without a session, matched exactly (`/login` is the SPA route
/// the shell serves before a session exists) — a route added under
/// `/auth/` tomorrow is gated until someone lists it here on purpose.
pub fn is_public(path: &str) -> bool {
    matches!(
        path,
        "/health" | "/login" | "/auth/status" | "/auth/logout" | "/auth/passkey/login/start" | "/auth/passkey/login/finish" | "/auth/passkey/register/start" | "/auth/passkey/register/finish"
    )
}

/// trunk emits `crossorigin="anonymous"` on its scripts and styles, so the
/// browser fetches them without the cookie. One leading slash and a listed
/// extension, nothing deeper — no console route is a single segment.
pub fn is_public_asset(method: &Method, path: &str) -> bool {
    const EXT: &[&str] = &[".wasm", ".js", ".css", ".woff2", ".woff", ".ttf", ".ico", ".png", ".svg", ".webp", ".map"];
    if !matches!(*method, Method::GET | Method::HEAD) {
        return false;
    }
    let Some(name) = path.strip_prefix('/') else { return false };
    !name.contains('/') && EXT.iter().any(|e| name.ends_with(e))
}

/// Who is calling, if anyone: a passkey session, or the break-glass bearer (always admin).
#[derive(Debug, Clone)]
pub enum Caller { Operator(Session), AdminToken }

impl Caller {
    pub fn role(&self) -> Role {
        match self { Caller::Operator(s) => s.role, Caller::AdminToken => Role::Admin }
    }
    pub fn name(&self) -> &str {
        match self { Caller::Operator(s) => &s.user, Caller::AdminToken => "admin-token" }
    }
}

/// Resolve the caller from a request's headers.
pub fn resolve(state: &AppState, headers: &HeaderMap) -> Option<Caller> {
    let offered = headers.get(http::header::AUTHORIZATION).and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ")).filter(|t| !t.is_empty());
    if let (Some(configured), Some(offered)) = (state.admin_token.as_deref(), offered) {
        if blake3::hash(configured.as_bytes()) == blake3::hash(offered.as_bytes()) {
            if state.webauthn.as_deref().is_none_or(Webauthn::admin_token_usable) {
                return Some(Caller::AdminToken);
            }
            tracing::warn!(target: AUDIT_TARGET, event = "auth.token_retired", "the admin token was offered and refused: an admin passkey exists");
        }
    }
    let webauthn = state.webauthn.as_ref()?;
    session_of(webauthn, headers).map(Caller::Operator)
}

/// The console's DEV-NARRATE routes (`/api/narrate/{estate}`, `…/{vm}`): admin only.
pub const NARRATE_PREFIX: &str = "/api/narrate/";

/// The account-wide Iron page's read (MAP-SEG 1): every estate's machines and disks, the
/// orphans and the missing — the operator's, an admin's.
pub const IRON_PATH: &str = "/api/iron";

/// **The role a request needs, decided in ONE place.** Reads are a viewer's
/// (a quote reserves nothing and is one); every mutation under `/api/` is an
/// admin's — an order, a refund, the operator `Entitlement.Set`, a backup — and
/// all of them are money. `/auth/…` routes carry their own gates. Anything
/// else that mutates is closed by default.
pub fn role_floor(method: &Method, path: &str) -> Role {
    // A narrated site's boot logs (lane N2) are the operator's: an admin's, reads included.
    if path.starts_with(NARRATE_PREFIX) {
        return Role::Admin;
    }
    // The account's iron, every estate's (MAP-SEG 1): the operator's, an admin's, reads included.
    if path == IRON_PATH {
        return Role::Admin;
    }
    if matches!(*method, Method::GET | Method::HEAD) || path.starts_with("/auth/") {
        return Role::Viewer;
    }
    Role::Admin
}

/// May the console run with NO credential at all, bound to `bind`? Only on
/// loopback; an unparseable bind is not confined.
pub fn open_console_is_confined(bind: &str) -> bool {
    if let Ok(addr) = bind.parse::<std::net::SocketAddr>() {
        return addr.ip().is_loopback();
    }
    matches!(bind.rsplit_once(':'), Some((host, _)) if host.eq_ignore_ascii_case("localhost"))
}

/// **Is this request a BROWSER NAVIGATION** (an address typed, a link
/// followed, a reload) rather than a fetch? `text/html` in `Accept`, or the
/// browser's own Fetch Metadata saying so (`Sec-Fetch-Mode: navigate`,
/// `Sec-Fetch-Dest: document`) — a navigation whose `Accept` is `*/*` is
/// still one (madmin-root-no-login-redirect, the owner on
/// monetize.ignalina.dk 2026-10-01: such a navigation was handed `401 log
/// in to use the console` instead of the login page). A fetch says `cors`/
/// `same-origin` and `empty`, and gets the 401 it can act on.
pub fn is_navigation(headers: &http::HeaderMap) -> bool {
    let has = |name: &str, want: &str| headers.get(name).and_then(|v| v.to_str().ok()).is_some_and(|v| v.trim().eq_ignore_ascii_case(want));
    headers.get(http::header::ACCEPT).and_then(|v| v.to_str().ok()).is_some_and(|a| a.contains("text/html")) || has("sec-fetch-mode", "navigate") || has("sec-fetch-dest", "document")
}

/// Refuse anything that is not public and has no caller. A browser navigation
/// is redirected to `/login`; a fetch gets a 401 it can act on.
pub async fn require_auth(State(state): State<AppState>, request: Request, next: Next) -> Response {
    let path = request.uri().path().to_owned();
    if is_public(&path) || is_public_asset(request.method(), &path) || state.is_open() {
        return next.run(request).await;
    }
    match resolve(&state, request.headers()) {
        Some(caller) => {
            let needed = role_floor(request.method(), &path);
            if !caller.role().allows(needed) {
                return (StatusCode::FORBIDDEN, format!("this needs the {needed} role and your session is {}", caller.role())).into_response();
            }
            if needs_step_up(request.method(), &path) && !stepped_up(&caller, now_secs()) {
                tracing::info!(target: AUDIT_TARGET, event = "auth.step_up_wanted", user = caller.name(), path = %path, "a weak session was asked for a fresh touch");
                return step_up_refusal();
            }
            let mut request = request;
            request.extensions_mut().insert(caller);
            next.run(request).await
        }
        None if state.passkey_enabled() && is_navigation(request.headers()) => Redirect::to("/login").into_response(),
        None => (StatusCode::UNAUTHORIZED, "log in to use the console").into_response(),
    }
}

// ── The /auth routes ──────────────────────────────────────────────────

type Answer = Result<Json<Value>, (StatusCode, String)>;

fn bad(m: impl Into<String>) -> (StatusCode, String) {
    (StatusCode::BAD_REQUEST, m.into())
}

fn passkey(state: &AppState) -> Result<&Webauthn, (StatusCode, String)> {
    state.webauthn.as_deref().ok_or((StatusCode::NOT_FOUND, "passkey login is not enabled".into()))
}

fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<Caller, (StatusCode, String)> {
    match resolve(state, headers) {
        Some(c) if c.role().allows(Role::Admin) => Ok(c),
        Some(_) => Err((StatusCode::FORBIDDEN, "this needs an admin passkey".into())),
        None => Err((StatusCode::UNAUTHORIZED, "log in first".into())),
    }
}

/// `GET /auth/status` — what the console needs before it decides to render.
pub async fn status(State(state): State<AppState>, headers: HeaderMap) -> Json<Value> {
    let caller = resolve(&state, &headers);
    // Who, on screen: an operator by its key's label (the handle is opaque).
    let who = match &caller {
        Some(Caller::Operator(s)) => state.webauthn.as_deref().and_then(|w| w.operator_label(&s.user)).unwrap_or_else(|| s.user.clone()),
        Some(c) => c.name().to_owned(),
        None => String::new(),
    };
    Json(json!({
        "authenticated": caller.is_some(),
        "user": who,
        "role": caller.as_ref().map(|c| c.role().as_token()),
        "passkey_enabled": state.passkey_enabled(),
        "open": state.is_open(),
        // Tells the login page whether "register the first admin" is still possible with the token.
        "bootstrap": state.admin_token.is_some() && state.webauthn.as_deref().is_none_or(Webauthn::admin_token_usable),
        // The signed-in operator already holds an enabled key here: the login page then offers
        // no enrol form until "Add another key" is pressed (owner 2026-10-19).
        "has_passkey": matches!(&caller, Some(Caller::Operator(s)) if state.webauthn.as_deref().is_some_and(|w| w.operator_label(&s.user).is_some())),
    }))
}

pub async fn logout() -> Response {
    ([(http::header::SET_COOKIE, session_cookie_str("", 0))], Json(json!({ "ok": true }))).into_response()
}

/// `GET /auth/passkey/login/start` — no account, ever: the discoverable
/// ceremony ([`Webauthn::login_options`]).
pub async fn login_start(State(state): State<AppState>) -> Answer {
    Ok(Json(passkey(&state)?.login_options().map_err(bad)?))
}

pub async fn login_finish(State(state): State<AppState>, Json(body): Json<Value>) -> Response {
    let w = match passkey(&state) { Ok(w) => w, Err(e) => return e.into_response() };
    match w.login_finish(&body) {
        Ok((cookie, operator, role, label)) => {
            tracing::info!(target: AUDIT_TARGET, event = "auth.login", operator = %operator, key = %label, role = %role, "an operator logged in");
            ([(http::header::SET_COOKIE, session_cookie_str(&cookie, SESSION_TTL_SECS))], Json(json!({ "ok": true, "operator": operator, "label": label, "role": role.as_token() }))).into_response()
        }
        Err(e) => {
            tracing::warn!(target: AUDIT_TARGET, event = "auth.login_failed", reason = %e, "a login was refused");
            (StatusCode::UNAUTHORIZED, e).into_response()
        }
    }
}

/// [`require_admin`], stepped up: an admin passkey touched within
/// [`STEP_UP_SECS`], or the break-glass token.
fn require_strong_admin(state: &AppState, headers: &HeaderMap) -> Result<Caller, Box<Response>> {
    let caller = require_admin(state, headers).map_err(|e| Box::new(e.into_response()))?;
    if !stepped_up(&caller, now_secs()) {
        return Err(Box::new(step_up_refusal()));
    }
    Ok(caller)
}

/// `GET /auth/passkey/register/start[?label=<device>]` — an admin (or the
/// break-glass token) enrols keys, with a fresh touch ([`needs_step_up`]). No
/// name: the token's enrolment is a new operator, a session's joins its own.
pub async fn register_start(State(state): State<AppState>, headers: HeaderMap, axum::extract::Query(q): axum::extract::Query<HashMap<String, String>>) -> Result<Json<Value>, Response> {
    let caller = require_strong_admin(&state, &headers).map_err(|r| *r)?;
    let w = passkey(&state).map_err(IntoResponse::into_response)?;
    let operator = match &caller { Caller::Operator(s) => Some(s.user.as_str()), Caller::AdminToken => None };
    Ok(Json(w.register_options(operator, q.get("label").map(String::as_str).unwrap_or("")).map_err(|e| bad(e).into_response())?))
}

pub async fn register_finish(State(state): State<AppState>, headers: HeaderMap, Json(body): Json<Value>) -> Result<Json<Value>, Response> {
    let caller = require_strong_admin(&state, &headers).map_err(|r| *r)?;
    let cred = passkey(&state).map_err(IntoResponse::into_response)?.register_finish(&body).map_err(|e| bad(e).into_response())?;
    tracing::info!(target: AUDIT_TARGET, event = "auth.register", by = caller.name(), operator = %cred.user, key = %cred.label, role = %cred.role, "a passkey was enrolled");
    Ok(Json(json!({ "ok": true, "id": cred.id })))
}

pub async fn credentials(State(state): State<AppState>, headers: HeaderMap) -> Answer {
    require_admin(&state, &headers)?;
    let creds: Vec<Value> = passkey(&state)?.credentials().iter().map(|c| json!({ "id": c.id, "label": c.label, "operator": c.user, "role": c.role, "created": c.created, "last_used": c.last_used, "disabled": c.disabled })).collect();
    Ok(Json(json!({ "credentials": creds })))
}

pub async fn sessions_revoke_all(State(state): State<AppState>, headers: HeaderMap) -> Answer {
    require_admin(&state, &headers)?;
    let epoch = passkey(&state)?.bump_epoch().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
    Ok(Json(json!({ "ok": true, "epoch": epoch })))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **Break-glass recovery**: every passkey is disabled and every session
    /// revoked, and the break-glass token (the one for the FIRST passkey) is
    /// usable again — the operator lost the device, and the console's SSH key
    /// (which every estate knows) must not be re-minted to recover.
    #[test]
    fn a_reset_disables_every_passkey_revokes_every_session_and_rearms_the_token() {
        let (w, _d) = test_webauthn();
        w.adopt(StoredCred { id: "k1".into(), x: vec![0; 32], y: vec![0; 32], sign_count: 0, label: "laptop".into(), created: 0, user: "operator".into(), role: Role::Admin, last_used: None, disabled: false }).unwrap();
        let cookie = w.mint_session("operator", Role::Admin, true);
        assert!(!w.admin_token_usable() && w.verify_cookie(&cookie).is_some());
        assert_eq!(w.reset_passkeys().unwrap(), 1);
        assert!(w.admin_token_usable(), "the break-glass token enrols the first passkey again");
        assert!(w.verify_cookie(&cookie).is_none(), "every session is gone");
        assert!(w.credentials().iter().all(|c| c.disabled), "kept, disabled — the record says who held it");
    }

    /// ★ **A session cookie in a later Cookie field, or behind a stale one of the same name, still
    /// signs in** (owner 2026-10-17: the login "worked" in Chrome and the console stayed logged out;
    /// Firefox was fine).
    #[test]
    fn a_session_in_a_later_cookie_field_or_behind_a_stale_one_is_found() {
        let (w, _d) = test_webauthn();
        let good = w.mint_session("op", Role::Admin, true);
        let stale = "c3RhbGU.c3RhbGU";
        let mut h = HeaderMap::new();
        h.append(http::header::COOKIE, "theme=dark".parse().unwrap());
        h.append(http::header::COOKIE, format!("{SESSION_COOKIE}={good}").parse().unwrap());
        assert_eq!(session_of(&w, &h).map(|s| s.user), Some("op".to_string()), "the second Cookie field");
        let mut h = HeaderMap::new();
        h.append(http::header::COOKIE, format!("{SESSION_COOKIE}={stale}; {SESSION_COOKIE}={good}").parse().unwrap());
        assert_eq!(session_of(&w, &h).map(|s| s.user), Some("op".to_string()), "behind a stale one");
        assert_eq!(session_cookies(&h).len(), 2);
        let mut h = HeaderMap::new();
        h.append(http::header::COOKIE, format!("{SESSION_COOKIE}={stale}").parse().unwrap());
        assert!(session_of(&w, &h).is_none());
    }

    pub(crate) fn test_webauthn() -> (Webauthn, tempfile::TempDir) {
        let dir = tempfile::tempdir().unwrap();
        let state = StateStore::open(dir.path().join("state.redb")).unwrap();
        let rp = RelyingParty { rp_id: "console.example".into(), origin: "https://console.example".into(), rp_name: "monetize".into() };
        (Webauthn::new(rp, state).unwrap(), dir)
    }

    #[test]
    fn a_minted_session_verifies_a_tampered_one_does_not_and_the_epoch_revokes() {
        let (w, _d) = test_webauthn();
        let cookie = w.mint_session("bob", Role::Viewer, true);
        let s = w.verify_cookie(&cookie).unwrap();
        assert_eq!((s.user.as_str(), s.role, s.uv), ("bob", Role::Viewer, true));
        let (payload, mac) = cookie.split_once('.').unwrap();
        let text = String::from_utf8(B64.decode(payload).unwrap()).unwrap();
        let forged = format!("{}.{mac}", B64.encode(text.replace("|viewer|", "|admin|")));
        assert!(w.verify_cookie(&forged).is_none(), "promoting the role must break the MAC");
        w.bump_epoch().unwrap();
        assert!(w.verify_cookie(&cookie).is_none(), "the epoch revokes every cookie");
    }

    #[test]
    fn the_admin_token_retires_once_an_admin_passkey_exists() {
        let (w, _d) = test_webauthn();
        assert!(w.admin_token_usable());
        w.adopt(StoredCred { id: "k1".into(), x: vec![0; 32], y: vec![0; 32], sign_count: 0, label: "yubikey".into(), created: 0, user: "bob".into(), role: Role::Admin, last_used: None, disabled: false }).unwrap();
        assert!(!w.admin_token_usable());
        assert!(w.adopt(StoredCred { id: "k1".into(), x: vec![], y: vec![], sign_count: 0, label: String::new(), created: 0, user: "x".into(), role: Role::Viewer, last_used: None, disabled: false }).is_err());
    }

    #[test]
    fn the_gate_tables_are_exact() {
        assert!(is_public("/auth/passkey/login/start") && !is_public("/auth/passkey/credentials") && !is_public("/api/products"));
        assert!(is_public_asset(&Method::GET, "/monetize-ui-web-abc_bg.wasm") && !is_public_asset(&Method::GET, "/api/x/a.js") && !is_public_asset(&Method::POST, "/a.js"));
        assert_eq!(role_floor(&Method::GET, "/api/quote"), Role::Viewer, "a quote reserves nothing");
        assert_eq!(role_floor(&Method::POST, "/api/purchase"), Role::Admin, "an order spends money");
        assert_eq!(role_floor(&Method::POST, "/api/entitlement/gunnar/t"), Role::Admin);
        assert_eq!(role_floor(&Method::POST, "/anything"), Role::Admin, "closed by default");
        assert_eq!(role_floor(&Method::GET, "/api/narrate/test3-holger-rs"), Role::Admin, "a site's boot logs are an admin's, reads included");
        assert_eq!(role_floor(&Method::GET, "/api/narrate/test3-holger-rs/main"), Role::Admin);
        // MAP-SEG 1: the account's iron, every estate's, is the operator's; an estate's own part is a viewer's.
        assert_eq!(role_floor(&Method::GET, IRON_PATH), Role::Admin, "the account-wide iron is an admin's");
        assert_eq!(role_floor(&Method::GET, "/api/products/gunnar/tenants"), Role::Viewer);
        assert_eq!(role_floor(&Method::POST, "/api/estate-orders/test3-holger-rs/settle"), Role::Admin, "Abandon this install (F2) is an admin's");
        assert!(open_console_is_confined("127.0.0.1:8091") && open_console_is_confined("localhost:8091"));
        assert!(!open_console_is_confined("0.0.0.0:8091") && !open_console_is_confined("garbage"));
    }

    /// ★ **What wants a fresh touch, decided in ONE place**: every mutation
    /// under `/api/` and enrolling a passkey; no read, no login, no logout.
    #[test]
    fn the_step_up_table_is_exact() {
        for p in ["/api/purchase", "/api/purchase/abandon", "/api/entitlement/gunnar/t", "/api/backup", "/api/guardian/acme/press"] {
            assert!(needs_step_up(&Method::POST, p), "POST {p}");
        }
        assert!(needs_step_up(&Method::GET, "/auth/passkey/register/start") && needs_step_up(&Method::POST, "/auth/passkey/register/finish"));
        assert!(!needs_step_up(&Method::GET, "/api/purchase/gunnar/t/2026-09-04") && !needs_step_up(&Method::GET, "/api/quote"));
        for p in ["/auth/passkey/login/start", "/auth/passkey/login/finish", "/auth/logout", "/auth/status"] {
            assert!(!needs_step_up(&Method::POST, p) && !needs_step_up(&Method::GET, p), "{p}");
        }
        let now = 1_000_000;
        let (w, _d) = test_webauthn();
        let fresh = w.verify_cookie(&w.mint_session("op", Role::Admin, true)).unwrap();
        assert!(stepped_up(&Caller::Operator(fresh), now_secs()));
        let old = w.verify_cookie(&w.mint_session_at("op", Role::Admin, true, now_secs() - STEP_UP_SECS - 1)).unwrap();
        assert!(!stepped_up(&Caller::Operator(old), now_secs()));
        assert!(stepped_up(&Caller::AdminToken, now), "the break-glass bearer is a token and stays one");
        assert!(strong(Role::Admin, true, now - STEP_UP_SECS, now) && !strong(Role::Admin, true, now - STEP_UP_SECS - 1, now));
    }

    fn offered(v: &Value) -> Vec<String> {
        v["publicKey"]["allowCredentials"].as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_owned()).collect()
    }

    fn held(w: &Webauthn, id: &str, operator: &str) {
        w.adopt(StoredCred { id: B64.encode(id), x: vec![0; 32], y: vec![0; 32], sign_count: 0, label: "yubikey".into(), created: 0, user: operator.into(), role: Role::Admin, last_used: None, disabled: false }).unwrap();
    }

    /// ★ **A login names nobody** (owner 2026-10-11: "creepy, take that
    /// away, we don't have any legacy"): `login/start` takes no account, offers
    /// no credential id, wants user verification, and does not grow with the
    /// keys — the authenticator answers with the discoverable passkey it keeps.
    #[test]
    fn a_login_names_nobody_offers_no_ones_passkey_and_wants_user_verification() {
        let (w, _d) = test_webauthn();
        held(&w, "rickards-yubikey", "op-rickard");
        let one = w.login_options().unwrap();
        assert!(offered(&one).is_empty(), "a login offers credential ids: {one}");
        assert_eq!(one["publicKey"]["userVerification"], "required", "{one}");
        for i in 0..40 {
            held(&w, &format!("key-of-operator-{i:02}"), &format!("op-{i}"));
        }
        let many = w.login_options().unwrap();
        assert_eq!(one["publicKey"]["allowCredentials"], many["publicKey"]["allowCredentials"]);
    }

    // ── A soft authenticator: the browser half, signed for real ────────

    mod soft {
        use super::super::B64;
        use base64::Engine as _;
        use p256::ecdsa::{signature::Signer, Signature, SigningKey};
        use serde_json::{json, Value};
        use sha2::{Digest, Sha256};

        pub const UP: u8 = 0x01;
        pub const UV: u8 = 0x04;
        const AT: u8 = 0x40;

        pub struct Key { pub sk: SigningKey, pub id: Vec<u8> }

        pub fn key(seed: u8) -> Key {
            Key { sk: SigningKey::from_slice(&[seed; 32]).unwrap(), id: vec![seed; 20] }
        }

        fn client_data(kind: &str, challenge: &str) -> Vec<u8> {
            serde_json::to_vec(&json!({ "type": kind, "challenge": challenge, "origin": "https://console.example", "crossOrigin": false })).unwrap()
        }

        fn auth_data(flags: u8, count: u32, attested: Option<&Key>) -> Vec<u8> {
            let mut out = Sha256::digest(b"console.example").to_vec();
            out.push(flags);
            out.extend_from_slice(&count.to_be_bytes());
            if let Some(k) = attested {
                out.extend_from_slice(&[0u8; 16]);
                out.extend_from_slice(&(k.id.len() as u16).to_be_bytes());
                out.extend_from_slice(&k.id);
                let point = k.sk.verifying_key().to_sec1_point(false);
                let (x, y) = point.as_bytes()[1..].split_at(32);
                let cose = ciborium::value::Value::Map(vec![
                    (1.into(), 2.into()),
                    (3.into(), (-7).into()),
                    ((-1).into(), 1.into()),
                    ((-2).into(), ciborium::value::Value::Bytes(x.to_vec())),
                    ((-3).into(), ciborium::value::Value::Bytes(y.to_vec())),
                ]);
                ciborium::into_writer(&cose, &mut out).unwrap();
            }
            out
        }

        /// `navigator.credentials.create(...).toJSON()` for `options`, plus
        /// what the console's page sends beside it.
        pub fn create(k: &Key, options: &Value, rk: Option<bool>, label: &str) -> Value {
            let challenge = options["publicKey"]["challenge"].as_str().unwrap();
            let att = ciborium::value::Value::Map(vec![
                ("fmt".into(), "none".into()),
                ("attStmt".into(), ciborium::value::Value::Map(vec![])),
                ("authData".into(), ciborium::value::Value::Bytes(auth_data(UP | UV | AT, 0, Some(k)))),
            ]);
            let mut att_bytes = Vec::new();
            ciborium::into_writer(&att, &mut att_bytes).unwrap();
            let ext = match rk { Some(rk) => json!({ "credProps": { "rk": rk } }), None => json!({}) };
            json!({
                "ceremony_id": options["ceremony_id"],
                "response": { "clientDataJSON": B64.encode(client_data("webauthn.create", challenge)), "attestationObject": B64.encode(att_bytes) },
                "clientExtensionResults": ext,
                "label": label,
                "role": "admin",
            })
        }

        /// `navigator.credentials.get(...).toJSON()`: `handle` is the user
        /// handle a discoverable credential answers with.
        pub fn get(k: &Key, options: &Value, flags: u8, handle: Option<&[u8]>) -> Value {
            let challenge = options["publicKey"]["challenge"].as_str().unwrap();
            let cd = client_data("webauthn.get", challenge);
            let ad = auth_data(flags, 0, None);
            let mut msg = ad.clone();
            msg.extend_from_slice(&Sha256::digest(&cd));
            let sig: Signature = k.sk.sign(&msg);
            let mut response = json!({ "clientDataJSON": B64.encode(&cd), "authenticatorData": B64.encode(&ad), "signature": B64.encode(sig.to_der().as_bytes()) });
            if let Some(h) = handle {
                response["userHandle"] = json!(B64.encode(h));
            }
            json!({ "ceremony_id": options["ceremony_id"], "id": B64.encode(&k.id), "response": response })
        }
    }

    /// ★ **Enrolment asks for no name** (owner 2026-10-11): the first admin
    /// enrols with the break-glass token and an optional device label; the
    /// key must keep the passkey itself (`residentKey: required`) and verify
    /// its user; every enrolment by the token is a NEW operator, an opaque
    /// handle the console mints — no account name exists to type or guess.
    #[test]
    fn enrolment_asks_for_no_name_and_wants_a_discoverable_user_verified_key() {
        let (w, _d) = test_webauthn();
        let a = w.register_options(None, "blue yubikey").unwrap();
        let b = w.register_options(None, "").unwrap();
        let sel = &a["publicKey"]["authenticatorSelection"];
        assert_eq!((sel["residentKey"].as_str(), sel["requireResidentKey"].as_bool(), sel["userVerification"].as_str()), (Some("required"), Some(true), Some("required")), "{a}");
        assert_eq!(a["publicKey"]["extensions"]["credProps"], true, "{a}");
        assert_ne!(a["publicKey"]["user"]["id"], b["publicKey"]["user"]["id"], "each token enrolment is its own operator");
        assert_eq!(a["publicKey"]["user"]["name"], "blue yubikey", "the picker shows the device label");
        assert!(b["publicKey"]["user"]["name"].as_str().is_some_and(|n| !n.is_empty()), "{b}");
        assert!(offered(&json!({ "publicKey": { "allowCredentials": a["publicKey"]["excludeCredentials"] } })).is_empty());
    }

    /// ★ **The round trip, usernameless**: enrol with no name, sign in with
    /// one button (no allowCredentials), and the credential's USER HANDLE
    /// says who it is — the session is that operator's. A second key enrolled
    /// by that operator's session joins the same operator.
    #[test]
    fn a_discoverable_key_signs_in_by_its_user_handle_and_a_later_key_joins_its_operator() {
        let (w, _d) = test_webauthn();
        let k = soft::key(7);
        let opts = w.register_options(None, "blue yubikey").unwrap();
        let handle = B64.decode(opts["publicKey"]["user"]["id"].as_str().unwrap()).unwrap();
        let cred = w.register_finish(&soft::create(&k, &opts, Some(true), "blue yubikey")).unwrap();
        assert_eq!(cred.user.as_bytes(), handle.as_slice(), "the stored operator IS the user handle");
        assert_eq!(cred.label, "blue yubikey");

        let login = w.login_options().unwrap();
        let (cookie, operator, role, label) = w.login_finish(&soft::get(&k, &login, soft::UP | soft::UV, Some(&handle))).unwrap();
        assert_eq!((operator.as_bytes(), role, label.as_str()), (handle.as_slice(), Role::Admin, "blue yubikey"));
        let s = w.verify_cookie(&cookie).unwrap();
        assert!(s.uv && s.user == operator);

        // A later key, by that operator's session: the same handle, and the
        // operator's own key is excluded (no second credential on one key).
        let more = w.register_options(Some(&operator), "spare").unwrap();
        assert_eq!(B64.decode(more["publicKey"]["user"]["id"].as_str().unwrap()).unwrap(), handle);
        assert_eq!(more["publicKey"]["excludeCredentials"][0]["id"], json!(cred.id));
        let k2 = soft::key(9);
        assert_eq!(w.register_finish(&soft::create(&k2, &more, Some(true), "spare")).unwrap().user, operator);
    }

    /// ★ **No legacy**: a login whose credential answers with no user handle
    /// (a key that keeps no discoverable passkey — every one enrolled under a
    /// name before 2026-10-11), with someone else's handle, or without user
    /// verification is refused; an enrolment the browser reports as NOT
    /// discoverable is refused before it is stored.
    #[test]
    fn a_login_without_the_right_user_handle_or_verification_is_refused() {
        let (w, _d) = test_webauthn();
        let k = soft::key(7);
        let opts = w.register_options(None, "").unwrap();
        let handle = B64.decode(opts["publicKey"]["user"]["id"].as_str().unwrap()).unwrap();
        w.register_finish(&soft::create(&k, &opts, None, "")).unwrap();
        for (flags, h, why) in [(soft::UP | soft::UV, None, "no user handle"), (soft::UP | soft::UV, Some(b"op-somebody-else".as_slice()), "a wrong handle"), (soft::UP, Some(handle.as_slice()), "no user verification")] {
            let login = w.login_options().unwrap();
            assert!(w.login_finish(&soft::get(&k, &login, flags, h)).is_err(), "{why} must be refused");
        }
        let login = w.login_options().unwrap();
        assert!(w.login_finish(&soft::get(&k, &login, soft::UP | soft::UV, Some(&handle))).is_ok(), "the control signs in");

        let not_resident = w.register_options(None, "").unwrap();
        let err = w.register_finish(&soft::create(&soft::key(3), &not_resident, Some(false), "")).unwrap_err();
        assert!(err.contains("discoverable"), "{err}");
        assert_eq!(w.credentials().len(), 1, "nothing stored");
    }

    #[test]
    fn a_login_needs_a_registered_key_and_a_live_ceremony() {
        let (w, _d) = test_webauthn();
        assert_eq!(w.login_options().unwrap_err(), "no passkey is registered yet");
        assert!(w.login_finish(&json!({ "ceremony_id": "nope", "id": "k" })).unwrap_err().contains("unknown or has expired"));
    }
}

/// R5a: every passkey sign-in takes a challenge from this book. A panic that
/// once held it must not lock every operator out of the console until a
/// restart; the book holds whole challenges (one insert, one remove).
#[cfg(test)]
mod poisoned_pending_tests {
    use super::*;

    #[test]
    fn a_poisoned_challenge_book_still_mints_and_takes() {
        let (w, _d) = tests::test_webauthn();
        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            let _held = w.pending.lock();
            panic!("a panic while the challenges are held");
        }));
        assert!(w.pending.is_poisoned());
        let (id, _) = w.new_challenge(Purpose::Login, None);
        assert!(w.take_challenge(&id, Purpose::Login).is_some());
        assert!(w.take_challenge(&id, Purpose::Login).is_none(), "single use");
    }
}