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

nordisk/holger

server/lib/tests/passkey_door_e2e.rs

//! **The ceremony door, proved against a real running holger.**
//!
//! Not a unit test. This boots an actual `Holger` with an actual HTTP listener and
//! an `AuthMethodConfig::Passkey`, and drives the four routes over the wire with a
//! soft authenticator standing in for a security key. The reason is a defect the
//! console lane found the same way and nothing else would have: holger is **two
//! listeners with disjoint door sets**, so a route added to the wrong one answers
//! 404 while every unit test in the repository stays green.
//!
//! What cannot be proved headlessly is stated rather than glossed: the browser's
//! own half — that Chrome will actually scope a credential to this `rp_id` and
//! refuse to release it elsewhere — is the browser's behaviour, not holger's. What
//! IS proved here is everything holger is responsible for: that a registration
//! stores a credential, that a login against it returns a session token, that the
//! token authenticates on an ordinary holger door, and that each of the refusals
//! the design promises actually fires over HTTP.

// The door only exists in a build that pinned the provider; saying so here beats
// nine mystery 404s in a tier that never meant to serve it.

use std::io::Write;
use traits::test_support::hold_port;

use holger_server_lib::{read_ron_config, wire_holger, Holger};
use nornir_auth_webauthn::softkey::SoftKey;

const RP_ID: &str = "holger.lab.internal";
const ORIGIN: &str = "https://holger.lab.internal";
const ADMIN_TOKEN: &str = "bootstrap-admin-token";

struct Server {
    _data: tempfile::TempDir,
    _cfg: tempfile::NamedTempFile,
    base: String,
}

/// Boot a real holger whose auth accepts BOTH a bootstrap admin bearer and
/// passkeys — which is exactly the airgap enrolment posture: the first credential
/// is enrolled by an identity that is already admin under another method, because
/// there is no mail, no IdP and no internet to enrol it any other way.
fn start() -> Server {
    let (grpc, http) = (hold_port(), hold_port());
    let data = tempfile::tempdir().unwrap();
    let ron = format!(
        r#"(
    repositories: [
        (ron_name: "raw", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
         ron_data_dir: Some("{data}")),
    ],
    exposed_endpoints: [
        (ron_name: "default", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")),
    ],
    storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{data}") ],
    // The audit dir is what anchors `data_root()`, and the passkey store lives
    // under it. Setting it per-test is not decoration: without it `data_root()`
    // falls back to `.`, every test in this file shares ONE credential store in
    // the working directory, and the second registration fails with "that
    // authenticator is already registered here". Found by writing it the other
    // way first.
    audit: (dir: "{data}/audit", enabled: true),
    auth: Some((
        methods: [
            StaticToken(subject: "bootstrap", token: Some("{admin}")),
            Passkey(rp_id: "{rp_id}", rp_origin: "{origin}"),
        ],
        roles: {{"bootstrap": Admin}},
        group_roles: {{"platform-admins": Admin, "writers": Writer, "readers": Reader}},
        default_role: Some(Reader),
    )),
)"#,
        data = data.path().display(),
        admin = ADMIN_TOKEN,
        rp_id = RP_ID,
        origin = ORIGIN,
    );
    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(ron.as_bytes()).unwrap();
    let mut holger: Holger = read_ron_config(cfg.path()).expect("the RON must parse");
    holger.instantiate_backends().unwrap();
    wire_holger(&mut holger).unwrap();
    holger.start().unwrap();
    Server {
        _data: data,
        _cfg: cfg,
        base: format!("http://127.0.0.1:{http}"),
    }
}

async fn wait_healthy(c: &reqwest::Client, base: &str) {
    for _ in 0..200 {
        if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
            if r.status() == 200 {
                return;
            }
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("the gateway never became healthy at {base}");
}

async fn post(
    c: &reqwest::Client,
    base: &str,
    path: &str,
    bearer: Option<&str>,
    body: serde_json::Value,
) -> (u16, serde_json::Value) {
    let mut req = c.post(format!("{base}{path}")).json(&body);
    if let Some(b) = bearer {
        req = req.bearer_auth(b);
    }
    let r = req.send().await.expect("the door answers");
    let status = r.status().as_u16();
    let text = r.text().await.unwrap_or_default();
    let json = serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text));
    (status, json)
}

/// Run a whole registration: `start` → the authenticator → `finish`.
async fn register(
    c: &reqwest::Client,
    base: &str,
    key: &mut SoftKey,
    bearer: Option<&str>,
    body: serde_json::Value,
) -> (u16, serde_json::Value) {
    let (status, start) = post(c, base, "/-/auth/passkey/register/start", bearer, body).await;
    if status != 200 {
        return (status, start);
    }
    let ceremony = start["ceremony_id"]
        .as_str()
        .expect("a ceremony id")
        .to_owned();
    let challenge = start["publicKey"]["challenge"]
        .as_str()
        .expect("a challenge");
    let (client_data, attestation) = key.register(RP_ID, ORIGIN, challenge, true);
    post(
        c,
        base,
        "/-/auth/passkey/register/finish",
        bearer,
        serde_json::json!({
            "ceremony_id": ceremony,
            "id": key.credential_id_b64(),
            "response": { "clientDataJSON": client_data, "attestationObject": attestation },
        }),
    )
    .await
}

/// Run a whole login: `start` → the authenticator → `finish`.
async fn login(c: &reqwest::Client, base: &str, key: &mut SoftKey) -> (u16, serde_json::Value) {
    login_uv(c, base, key, true).await
}

/// A login whose authenticator does (`uv`) or does not verify the person.
async fn login_uv(
    c: &reqwest::Client,
    base: &str,
    key: &mut SoftKey,
    uv: bool,
) -> (u16, serde_json::Value) {
    let (status, start) = post(
        c,
        base,
        "/-/auth/passkey/login/start",
        None,
        serde_json::json!({}),
    )
    .await;
    if status != 200 {
        return (status, start);
    }
    let ceremony = start["ceremony_id"]
        .as_str()
        .expect("a ceremony id")
        .to_owned();
    let challenge = start["publicKey"]["challenge"]
        .as_str()
        .expect("a challenge");
    let (client_data, auth_data, signature) = key.authenticate(RP_ID, ORIGIN, challenge, uv);
    post(
        c,
        base,
        "/-/auth/passkey/login/finish",
        None,
        serde_json::json!({
            "ceremony_id": ceremony,
            "id": key.credential_id_b64(),
            "response": {
                "clientDataJSON": client_data,
                "authenticatorData": auth_data,
                "signature": signature,
                "userHandle": null,
            },
        }),
    )
    .await
}

// ─── The round trip ──────────────────────────────────────────────────────────

/// **The test the owner asked for: a person can log in.**
///
/// Enrol a passkey with the bootstrap admin bearer, then log in with nothing but
/// the authenticator, then use the resulting token on an ORDINARY holger door —
/// which is the whole claim, because it proves the ceremony hands back a
/// credential the existing stateless seam already understands.
#[tokio::test(flavor = "multi_thread")]
async fn a_passkey_registers_then_logs_in_and_the_token_opens_an_ordinary_door() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;

    let mut key = SoftKey::new(0x42, b"cred-anna-0001");
    let (status, reg) = register(
        &c,
        &s.base,
        &mut key,
        Some(ADMIN_TOKEN),
        serde_json::json!({ "display": "anna", "label": "anna's yubikey", "groups": ["platform-admins"] }),
    )
    .await;
    assert_eq!(status, 200, "registration must succeed: {reg}");

    // The subject is OPAQUE and minted here — never the friendly name. It is what
    // every roles map keys on, and a name somebody can change is a privilege
    // somebody can move.
    let subject = reg["subject"].as_str().expect("a subject").to_owned();
    assert!(
        subject.starts_with("u-") && subject.len() == 18,
        "the subject must be an opaque enrolment-minted id (the identity core's), got {subject:?}"
    );
    assert_ne!(subject, "anna", "the subject must NOT be the display name");

    let (status, out) = login(&c, &s.base, &mut key).await;
    assert_eq!(status, 200, "login must succeed: {out}");
    let token = out["token"].as_str().expect("a session token").to_owned();
    assert_eq!(
        out["display"].as_str(),
        Some("anna"),
        "the audit name comes back"
    );

    // THE claim: the minted token is an ordinary bearer on an ordinary door, and
    // the group the enrolment recorded resolved to Admin through the SAME ladder
    // an OIDC identity walks.
    let r = c
        .get(format!("{}/-/search?name=anything", s.base))
        .bearer_auth(&token)
        .send()
        .await
        .expect("the search door answers");
    assert_eq!(
        r.status().as_u16(),
        200,
        "the passkey session token must authenticate on an ordinary holger door"
    );
}

/// `login/start` takes **no username**, and proves it by working for an
/// authenticator the server was told nothing about beforehand. A username here
/// would make this unauthenticated route an account-existence oracle for anyone
/// who can reach it.
#[tokio::test(flavor = "multi_thread")]
async fn login_start_names_nobody_and_lists_no_credentials() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;

    let (status, start) = post(
        &c,
        &s.base,
        "/-/auth/passkey/login/start",
        None,
        serde_json::json!({}),
    )
    .await;
    assert_eq!(
        status, 200,
        "the login door is open to an anonymous caller: {start}"
    );
    assert_eq!(
        start["publicKey"]["allowCredentials"]
            .as_array()
            .map(Vec::len),
        Some(0),
        "allowCredentials must be EMPTY — listing them tells an anonymous caller \
         which keys this server knows"
    );
    assert_eq!(start["publicKey"]["rpId"].as_str(), Some(RP_ID));
    assert!(
        start.get("user").is_none(),
        "no user is named at login start"
    );
}

/// A ceremony is **single use**. Replaying a `finish` against a spent ceremony id
/// is a 404, which is what stops a captured assertion being replayed.
#[tokio::test(flavor = "multi_thread")]
async fn a_ceremony_cannot_be_finished_twice() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;

    let mut key = SoftKey::new(0x42, b"cred-anna-0001");
    register(
        &c,
        &s.base,
        &mut key,
        Some(ADMIN_TOKEN),
        serde_json::json!({ "display": "anna" }),
    )
    .await;

    // Drive a login by hand so the same ceremony id can be presented twice.
    let (_, start_body) = post(
        &c,
        &s.base,
        "/-/auth/passkey/login/start",
        None,
        serde_json::json!({}),
    )
    .await;
    let ceremony = start_body["ceremony_id"].as_str().unwrap().to_owned();
    let challenge = start_body["publicKey"]["challenge"].as_str().unwrap();
    let (cd, ad, sig) = key.authenticate(RP_ID, ORIGIN, challenge, true);
    let finish = serde_json::json!({
        "ceremony_id": ceremony,
        "id": key.credential_id_b64(),
        "response": { "clientDataJSON": cd, "authenticatorData": ad, "signature": sig, "userHandle": null },
    });

    let (first, _) = post(
        &c,
        &s.base,
        "/-/auth/passkey/login/finish",
        None,
        finish.clone(),
    )
    .await;
    assert_eq!(first, 200, "the first finish succeeds");
    let (second, _) = post(&c, &s.base, "/-/auth/passkey/login/finish", None, finish).await;
    assert_eq!(
        second, 404,
        "a spent ceremony must be gone, not merely stale"
    );
}

/// An unauthenticated caller cannot enrol a passkey. Without this the login door
/// is decoration: anyone who can reach the server registers their own key and is
/// an operator.
#[tokio::test(flavor = "multi_thread")]
async fn registration_refuses_an_unauthenticated_caller() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;

    let (status, _) = post(
        &c,
        &s.base,
        "/-/auth/passkey/register/start",
        None,
        serde_json::json!({ "display": "mallory" }),
    )
    .await;
    assert_eq!(status, 401, "enrolment must demand authority");

    let (status, _) = post(
        &c,
        &s.base,
        "/-/auth/passkey/register/start",
        Some("not-the-admin-token"),
        serde_json::json!({ "display": "mallory" }),
    )
    .await;
    assert_eq!(status, 401, "a wrong bearer must not enrol");
}

// ─── Invites: how the tenth credential gets in ───────────────────────────────

/// An admin mints a **single-use, short-TTL invite**; the invitee redeems it at
/// `register/finish` with no admin credential of their own. That is the whole
/// enrolment story past the first key, and it works with no mail, no IdP and no
/// internet — the invite is delivered out of band, read aloud or shown as a QR.
#[tokio::test(flavor = "multi_thread")]
async fn an_invite_enrols_the_tenth_credential_and_is_single_use() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;

    let (status, minted) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(ADMIN_TOKEN),
        serde_json::json!({ "display": "bo", "groups": ["readers"] }),
    )
    .await;
    assert_eq!(status, 200, "an admin mints an invite: {minted}");
    let secret = minted["invite"]
        .as_str()
        .expect("the secret, shown once")
        .to_owned();

    // The store holds the HASH, never the secret.
    let (_, listed) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites/list",
        Some(ADMIN_TOKEN),
        serde_json::json!({}),
    )
    .await;
    let listed = listed["invites"].as_array().expect("a list");
    assert_eq!(listed.len(), 1);
    assert_ne!(
        listed[0]["id"].as_str(),
        Some(secret.as_str()),
        "the stored id must be the hash of the secret, never the secret"
    );

    // Bo redeems it with no admin credential at all.
    let mut bo = SoftKey::new(0x77, b"cred-bo-0001");
    let (status, reg) = register(
        &c,
        &s.base,
        &mut bo,
        None,
        serde_json::json!({ "display": "bo", "invite": secret }),
    )
    .await;
    assert_eq!(status, 200, "a valid invite enrols without admin: {reg}");

    // And it is SPENT: the same secret cannot enrol a second authenticator.
    let mut impostor = SoftKey::new(0x88, b"cred-impostor-0001");
    let (status, _) = register(
        &c,
        &s.base,
        &mut impostor,
        None,
        serde_json::json!({ "display": "mallory", "invite": secret }),
    )
    .await;
    assert_eq!(
        status, 401,
        "a spent invite must not enrol a second credential"
    );

    // Bo can log in, and resolves to Reader through the group the INVITE pinned —
    // not through anything Bo asked for.
    let (status, out) = login(&c, &s.base, &mut bo).await;
    assert_eq!(status, 200, "bo logs in: {out}");
    assert_eq!(out["role"].as_str(), Some("Reader"));
}

/// **The invite's word beats the body's.** An invitee who asks for a better group
/// than the invite pinned gets the invite's, because the invite is the admin's
/// statement and the body is the invitee's.
#[tokio::test(flavor = "multi_thread")]
async fn an_invitee_cannot_promote_themselves_in_the_body() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;

    let (_, minted) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(ADMIN_TOKEN),
        serde_json::json!({ "display": "bo", "groups": ["readers"] }),
    )
    .await;
    let secret = minted["invite"].as_str().unwrap().to_owned();

    let mut bo = SoftKey::new(0x77, b"cred-bo-0001");
    let (status, _) = register(
        &c,
        &s.base,
        &mut bo,
        None,
        serde_json::json!({ "display": "bo", "invite": secret, "groups": ["platform-admins"] }),
    )
    .await;
    assert_eq!(status, 200);

    let (_, out) = login(&c, &s.base, &mut bo).await;
    assert_eq!(
        out["role"].as_str(),
        Some("Reader"),
        "the group the INVITE pinned decides, never the one the body asked for"
    );
}

// ─── The break-glass retirement rule ─────────────────────────────────────────

/// **A break-glass credential that keeps working after a real one exists is the
/// one that leaks.** Once an enabled ADMIN passkey exists, the bootstrap bearer
/// stops being able to enrol passkeys — while remaining a perfectly good
/// credential for everything else, which is what keeps a relocated box reachable.
#[tokio::test(flavor = "multi_thread")]
async fn the_bootstrap_bearer_retires_once_an_admin_passkey_exists() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;

    // First key: enrolled by the bootstrap bearer, into an admin group.
    let mut anna = SoftKey::new(0x42, b"cred-anna-0001");
    let (status, _) = register(
        &c,
        &s.base,
        &mut anna,
        Some(ADMIN_TOKEN),
        serde_json::json!({ "display": "anna", "groups": ["platform-admins"] }),
    )
    .await;
    assert_eq!(
        status, 200,
        "the first passkey enrols on the bootstrap path"
    );

    // Second attempt on the same path is now refused — the door has retired.
    let mut bo = SoftKey::new(0x77, b"cred-bo-0001");
    let (status, body) = register(
        &c,
        &s.base,
        &mut bo,
        Some(ADMIN_TOKEN),
        serde_json::json!({ "display": "bo" }),
    )
    .await;
    assert_eq!(
        status, 403,
        "the bootstrap bearer must stop enrolling once an admin passkey exists: {body}"
    );

    // The bearer is NOT dead as a credential — it still opens ordinary doors, and
    // that is deliberate: it is what gets an operator back in after a rename.
    let r = c
        .get(format!("{}/-/search?name=anything", s.base))
        .bearer_auth(ADMIN_TOKEN)
        .send()
        .await
        .unwrap();
    assert_eq!(
        r.status().as_u16(),
        200,
        "the bootstrap bearer still authenticates"
    );

    // And an ADMIN PASSKEY can still enrol — the path moved, it did not close.
    let (_, out) = login(&c, &s.base, &mut anna).await;
    let annas_token = out["token"].as_str().expect("anna logs in").to_owned();
    let (status, body) = register(
        &c,
        &s.base,
        &mut bo,
        Some(&annas_token),
        serde_json::json!({ "display": "bo" }),
    )
    .await;
    assert_eq!(status, 200, "an admin passkey enrols the next one: {body}");
}

// ─── The config door the console needs for the renamed-box message ───────────

/// The console cannot tell an operator *"your keys were registered for X, you are
/// at Y"* without knowing X. This door is how it learns, and it is deliberately
/// open: `rp_id` is not a secret — it is in every ceremony the login door already
/// hands an anonymous caller.
#[tokio::test(flavor = "multi_thread")]
async fn the_config_door_tells_the_console_which_name_the_keys_belong_to() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;

    let r = c
        .get(format!("{}/-/auth/passkey/config", s.base))
        .send()
        .await
        .unwrap();
    assert_eq!(r.status().as_u16(), 200);
    let body: serde_json::Value = r.json().await.unwrap();
    assert_eq!(body["rp_id"].as_str(), Some(RP_ID));
    assert_eq!(body["enabled"].as_bool(), Some(true));
}

/// A server with NO passkey method configured answers the whole surface 404 —
/// including the config door, so a console can tell "not configured" from
/// "configured and refusing" without a special case.
#[tokio::test(flavor = "multi_thread")]
async fn a_server_without_passkeys_answers_the_whole_surface_404() {
    let (grpc, http) = (hold_port(), hold_port());
    let data = tempfile::tempdir().unwrap();
    let ron = format!(
        r#"(
    repositories: [
        (ron_name: "raw", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
         ron_data_dir: Some("{data}")),
    ],
    exposed_endpoints: [
        (ron_name: "default", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")),
    ],
    storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{data}") ],
    audit: (dir: "{data}/audit", enabled: true),
    auth: Some(( methods: [ StaticToken(subject: "ci", token: Some("secret")) ] )),
)"#,
        data = data.path().display(),
    );
    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(ron.as_bytes()).unwrap();
    let mut holger: Holger = read_ron_config(cfg.path()).unwrap();
    holger.instantiate_backends().unwrap();
    wire_holger(&mut holger).unwrap();
    holger.start().unwrap();
    let base = format!("http://127.0.0.1:{http}");

    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &base).await;

    let r = c
        .get(format!("{base}/-/auth/passkey/config"))
        .send()
        .await
        .unwrap();
    assert_eq!(
        r.status().as_u16(),
        404,
        "no passkey method ⇒ no passkey surface"
    );
    let (status, _) = post(
        &c,
        &base,
        "/-/auth/passkey/login/start",
        None,
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 404);
}

// ─── The console's decisions, moved here (thin-layer move, 2026-10-04) ───────

/// Enrol anna as an admin on the bootstrap path and log her in (user-verified).
async fn admin_anna(c: &reqwest::Client, base: &str) -> (SoftKey, String) {
    let mut anna = SoftKey::new(0x42, b"cred-anna-0001");
    let (status, reg) = register(
        c,
        base,
        &mut anna,
        Some(ADMIN_TOKEN),
        serde_json::json!({ "display": "anna", "role": "admin" }),
    )
    .await;
    assert_eq!(status, 200, "anna enrols as admin BY ROLE: {reg}");
    let (status, out) = login(c, base, &mut anna).await;
    assert_eq!(status, 200, "anna logs in: {out}");
    (anna, out["token"].as_str().expect("a token").to_owned())
}

/// **Step-up is holger's rule now.** An admin whose session was minted from a
/// touch-only assertion may not mint an invite; the refusal carries the
/// machine-readable `"code":"stepup"` (A28 M9), and its first words stay
/// "log in again" for a console older than M9. `login/start {"uv":"required"}`
/// asks the authenticator to verify the person, and that session may.
#[tokio::test(flavor = "multi_thread")]
async fn minting_an_invite_needs_a_user_verified_session() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;
    let (mut anna, _) = admin_anna(&c, &s.base).await;

    let (status, out) = login_uv(&c, &s.base, &mut anna, false).await;
    assert_eq!(status, 200, "a touch-only login still logs in: {out}");
    let touch_only = out["token"].as_str().unwrap().to_owned();
    let (status, body) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(&touch_only),
        serde_json::json!({ "role": "reader" }),
    )
    .await;
    assert_eq!(status, 403, "{body}");
    assert_eq!(
        body["code"].as_str(),
        Some(crate_code()),
        "the console's contract: {body}"
    );
    assert!(
        body["error"]
            .as_str()
            .unwrap_or_default()
            .starts_with("log in again"),
        "an older console's cue: {body}"
    );

    let (_, start) = post(
        &c,
        &s.base,
        "/-/auth/passkey/login/start",
        None,
        serde_json::json!({ "uv": "required" }),
    )
    .await;
    assert_eq!(
        start["publicKey"]["userVerification"].as_str(),
        Some("required"),
        "the step-up login asks for UV: {start}"
    );
    let (_, out) = login_uv(&c, &s.base, &mut anna, true).await;
    let verified = out["token"].as_str().unwrap().to_owned();
    let (status, body) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(&verified),
        serde_json::json!({ "role": "writer" }),
    )
    .await;
    assert_eq!(status, 200, "a user-verified admin mints: {body}");
    assert_eq!(body["role"].as_str(), Some("writer"));
    assert_eq!(
        body["groups"],
        serde_json::json!(["writers"]),
        "the role became the group the POLICY maps to it"
    );
}

fn crate_code() -> &'static str {
    holger_server_lib::passkey::STEP_UP_CODE
}

/// The console's answer to `"code":"stepup"` (A28 M9, Gunnar's pattern): the options for a
/// user-verified touch, the touch of `key`, the finish, all on the session it already has.
async fn step_up(
    c: &reqwest::Client,
    base: &str,
    session: &str,
    key: &mut SoftKey,
    uv: bool,
) -> (u16, serde_json::Value) {
    let (status, start) = post(
        c,
        base,
        "/-/auth/passkey/step-up/start",
        Some(session),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 200, "the step-up options: {start}");
    assert_eq!(
        start["publicKey"]["userVerification"].as_str(),
        Some("required"),
        "a step-up asks for UV: {start}"
    );
    let ceremony = start["ceremony_id"]
        .as_str()
        .expect("a ceremony id")
        .to_owned();
    let challenge = start["publicKey"]["challenge"]
        .as_str()
        .expect("a challenge");
    let (client_data, auth_data, signature) = key.authenticate(RP_ID, ORIGIN, challenge, uv);
    post(
        c,
        base,
        "/-/auth/passkey/step-up/finish",
        Some(session),
        serde_json::json!({
            "ceremony_id": ceremony,
            "id": key.credential_id_b64(),
            "response": { "clientDataJSON": client_data, "authenticatorData": auth_data, "signature": signature, "userHandle": null },
        }),
    )
    .await
}

/// ★ **A stale touch is answered in place** (A28 M9, owner 2026-10-19 "idiotic"; Gunnar's
/// b216393): a session without a fresh user-verified touch is refused with the
/// machine-readable `"code":"stepup"`, never a role refusal; a user-verified touch of the
/// person's OWN key at `/-/auth/passkey/step-up/*` mints the session again, and the refused
/// call then goes through. The check is as strict as before: a plain touch, another person's
/// key, or nobody signed in steps nobody up.
#[tokio::test(flavor = "multi_thread")]
async fn a_stale_touch_is_stepped_up_in_place_and_the_call_goes_through() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;
    let (mut anna, verified) = admin_anna(&c, &s.base).await;
    let (_, minted) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(&verified),
        serde_json::json!({ "role": "reader" }),
    )
    .await;
    let secret = minted["invite"]
        .as_str()
        .expect("an invite for bob")
        .to_owned();
    let mut bob = SoftKey::new(0x43, b"cred-bob-00001");
    let (status, reg) = register(
        &c,
        &s.base,
        &mut bob,
        None,
        serde_json::json!({ "invite": secret, "display": "bob" }),
    )
    .await;
    assert_eq!(status, 200, "bob enrols: {reg}");

    let (_, out) = login_uv(&c, &s.base, &mut anna, false).await;
    let stale = out["token"]
        .as_str()
        .expect("a touch-only session")
        .to_owned();
    let invite = || serde_json::json!({ "role": "reader" });
    let (status, refused) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(&stale),
        invite(),
    )
    .await;
    assert_eq!(status, 403, "{refused}");
    assert_eq!(
        refused["code"].as_str(),
        Some("stepup"),
        "machine-readable: {refused}"
    );
    assert!(
        !refused["error"]
            .as_str()
            .unwrap_or_default()
            .contains("role"),
        "{refused}"
    );
    for door in [
        "/-/auth/passkey/sessions/revoke-all",
        "/-/auth/passkey/credentials/revoke",
    ] {
        let (status, body) = post(
            &c,
            &s.base,
            door,
            Some(&stale),
            serde_json::json!({ "id": "nobody" }),
        )
        .await;
        assert_eq!(
            (status, body["code"].as_str()),
            (403, Some("stepup")),
            "{door}: {body}"
        );
    }

    let (status, body) = post(
        &c,
        &s.base,
        "/-/auth/passkey/step-up/start",
        None,
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 401, "nobody signed in steps nobody up: {body}");
    let (status, body) = step_up(&c, &s.base, &stale, &mut bob, true).await;
    assert_eq!(
        status, 403,
        "another person's key is not anna's touch: {body}"
    );
    assert!(
        body.get("token").is_none(),
        "no session for a key that names someone else: {body}"
    );
    let (status, body) = step_up(&c, &s.base, &stale, &mut anna, false).await;
    assert_eq!(
        status, 403,
        "a plain touch is not a user-verified one: {body}"
    );
    assert!(body.get("token").is_none(), "{body}");
    let (status, body) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(&stale),
        invite(),
    )
    .await;
    assert_eq!(
        (status, body["code"].as_str()),
        (403, Some("stepup")),
        "still stale: {body}"
    );

    let (status, fresh) = step_up(&c, &s.base, &stale, &mut anna, true).await;
    assert_eq!(status, 200, "anna's own user-verified touch: {fresh}");
    // The identity core steps the session up IN PLACE: the same token, fresh now.
    assert_eq!(
        (fresh["signed_in"].as_bool(), fresh["stepped_up"].as_bool()),
        (Some(true), Some(true)),
        "{fresh}"
    );
    let fresh = stale.clone();
    let (status, me) = post(
        &c,
        &s.base,
        "/-/auth/passkey/session",
        Some(&fresh),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(
        (status, me["display"].as_str(), me["stepped_up"].as_bool()),
        (200, Some("anna"), Some(true)),
        "{me}"
    );
    let (status, body) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(&fresh),
        invite(),
    )
    .await;
    assert_eq!(
        status, 200,
        "the refused call goes through after the touch: {body}"
    );
}

/// **An invite cannot grant admin**, by role or by group — a link that travels
/// out of band must never make its holder an administrator.
#[tokio::test(flavor = "multi_thread")]
async fn an_invite_cannot_grant_admin() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;
    for body in [
        serde_json::json!({ "role": "admin" }),
        serde_json::json!({ "groups": ["platform-admins"] }),
    ] {
        let (status, out) = post(
            &c,
            &s.base,
            "/-/auth/passkey/invites",
            Some(ADMIN_TOKEN),
            body.clone(),
        )
        .await;
        assert_eq!(status, 403, "{body}: {out}");
    }
}

/// **An invite with no name lets the invitee choose one — never somebody
/// else's.** A taken name is refused (it would have reused that person's
/// subject: an account takeover), and a free one gets a FRESH subject.
#[tokio::test(flavor = "multi_thread")]
async fn an_unnamed_invite_cannot_take_an_existing_persons_name() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;
    let (_, anna_token) = admin_anna(&c, &s.base).await;
    let (_, minted) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(&anna_token),
        serde_json::json!({ "role": "reader" }),
    )
    .await;
    let secret = minted["invite"].as_str().expect("an invite").to_owned();

    let mut mallory = SoftKey::new(0x66, b"cred-mallory-0001");
    let (status, out) = register(
        &c,
        &s.base,
        &mut mallory,
        None,
        serde_json::json!({ "invite": secret, "display": "anna" }),
    )
    .await;
    assert_eq!(status, 409, "anna's name is anna's: {out}");

    let (status, reg) = register(
        &c,
        &s.base,
        &mut mallory,
        None,
        serde_json::json!({ "invite": secret, "display": "mallory" }),
    )
    .await;
    assert_eq!(status, 200, "a free name redeems: {reg}");
    let (_, out) = login(&c, &s.base, &mut mallory).await;
    assert_eq!(out["role"].as_str(), Some("Reader"));
}

/// **Logging out ends the session at holger** — every door, not just a cookie.
#[tokio::test(flavor = "multi_thread")]
async fn logout_ends_the_session_everywhere() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;
    let (_, token) = admin_anna(&c, &s.base).await;
    let (status, me) = post(
        &c,
        &s.base,
        "/-/auth/passkey/session",
        Some(&token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 200, "{me}");
    assert_eq!(
        (
            me["display"].as_str(),
            me["role"].as_str(),
            me["stepped_up"].as_bool()
        ),
        (Some("anna"), Some("admin"), Some(true))
    );

    let (status, _) = post(
        &c,
        &s.base,
        "/-/auth/passkey/logout",
        Some(&token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 200);
    let (status, _) = post(
        &c,
        &s.base,
        "/-/auth/passkey/session",
        Some(&token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 401, "a logged-out session is no session");
    let (status, _) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites/list",
        Some(&token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 401, "nor an admin any more");
}

/// **A revoked credential takes its sessions with it, and the last admin is
/// never revoked.**
#[tokio::test(flavor = "multi_thread")]
async fn revoking_a_credential_ends_its_sessions_but_never_the_last_admin() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;
    let (anna, anna_token) = admin_anna(&c, &s.base).await;

    let (status, out) = post(
        &c,
        &s.base,
        "/-/auth/passkey/credentials/revoke",
        Some(&anna_token),
        serde_json::json!({ "id": anna.credential_id_b64() }),
    )
    .await;
    assert_eq!(status, 409, "the last admin passkey stays: {out}");

    let mut bo = SoftKey::new(0x77, b"cred-bo-0001");
    let (status, _) = register(
        &c,
        &s.base,
        &mut bo,
        Some(&anna_token),
        serde_json::json!({ "display": "bo", "role": "writer" }),
    )
    .await;
    assert_eq!(status, 200);
    let (_, out) = login(&c, &s.base, &mut bo).await;
    let bo_token = out["token"].as_str().unwrap().to_owned();
    let (_, listed) = post(
        &c,
        &s.base,
        "/-/auth/passkey/credentials/list",
        Some(&anna_token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(listed["enabled_admins"].as_u64(), Some(1), "{listed}");
    let bo_row = listed["credentials"]
        .as_array()
        .unwrap()
        .iter()
        .find(|r| r["display"] == "bo")
        .expect("bo is listed")
        .clone();
    assert_eq!(bo_row["role"].as_str(), Some("writer"));

    let (status, out) = post(
        &c,
        &s.base,
        "/-/auth/passkey/credentials/revoke",
        Some(&anna_token),
        serde_json::json!({ "id": bo.credential_id_b64() }),
    )
    .await;
    assert_eq!(status, 200, "{out}");
    let (status, _) = post(
        &c,
        &s.base,
        "/-/auth/passkey/session",
        Some(&bo_token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 401, "bo's session died with bo's credential");
    let (status, _) = login(&c, &s.base, &mut bo).await;
    assert_eq!(status, 401, "and bo's key logs in no more");
}

/// **Revoke-all is the hammer**: every session ends, nobody's key is touched.
#[tokio::test(flavor = "multi_thread")]
async fn revoke_all_ends_every_session_and_keeps_every_key() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;
    let (mut anna, token) = admin_anna(&c, &s.base).await;
    // One second apart: the floor is a timestamp, and a session minted in the
    // same second as the floor is (deliberately) still above it.
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
    let (status, out) = post(
        &c,
        &s.base,
        "/-/auth/passkey/sessions/revoke-all",
        Some(&token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 200, "{out}");
    let (status, _) = post(
        &c,
        &s.base,
        "/-/auth/passkey/session",
        Some(&token),
        serde_json::json!({}),
    )
    .await;
    assert_eq!(status, 401);
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
    let (status, _) = login(&c, &s.base, &mut anna).await;
    assert_eq!(status, 200, "the key still logs in");
}

/// **A console carries every person's ceremony from one address**, so a
/// caller that proves an ADMIN identity may name whose ceremony it is — and a
/// flood from one person then fills only that person's partition.
#[tokio::test(flavor = "multi_thread")]
async fn a_proven_console_partitions_ceremonies_by_the_person_it_names() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;
    let start_for = |who: &'static str, proof: Option<&'static str>| {
        let mut req = c
            .post(format!("{}/-/auth/passkey/login/start", s.base))
            .json(&serde_json::json!({}))
            .header("x-holger-ceremony-client", who);
        if let Some(p) = proof {
            req = req.header("x-holger-proxy-authorization", format!("Bearer {p}"));
        }
        async move { req.send().await.unwrap().status().as_u16() }
    };
    for _ in 0..16 {
        assert_eq!(start_for("10.9.9.1", Some(ADMIN_TOKEN)).await, 200);
    }
    assert_eq!(
        start_for("10.9.9.1", Some(ADMIN_TOKEN)).await,
        429,
        "the flooder meets its own cap"
    );
    assert_eq!(
        start_for("10.9.9.2", Some(ADMIN_TOKEN)).await,
        200,
        "another person goes on logging in"
    );
    // An unproven hint is ignored: these all land on the loopback peer's own
    // partition, which the hinted flood above never touched.
    assert_eq!(
        start_for("10.9.9.1", Some("not-the-admin-token")).await,
        200
    );
}

/// **A machine's name is never a person's** (gunnar d1a2f82, in holger's
/// words): the static bearer's subject, the console's own names and anything
/// shaped like a subject are refused at every door a name comes in by.
#[tokio::test(flavor = "multi_thread")]
async fn a_machines_name_is_never_a_persons() {
    let s = start();
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &s.base).await;
    for name in [
        "bootstrap",
        "Bootstrap",
        "holger-ui",
        "admin-token",
        "anonymous",
        "passkey:abc",
    ] {
        let (status, out) = post(
            &c,
            &s.base,
            "/-/auth/passkey/register/start",
            Some(ADMIN_TOKEN),
            serde_json::json!({ "display": name }),
        )
        .await;
        assert_eq!(status, 400, "{name}: {out}");
        let (status, out) = post(
            &c,
            &s.base,
            "/-/auth/passkey/invites",
            Some(ADMIN_TOKEN),
            serde_json::json!({ "role": "reader", "display": name }),
        )
        .await;
        assert_eq!(status, 400, "a pinned {name}: {out}");
    }
    let (_, minted) = post(
        &c,
        &s.base,
        "/-/auth/passkey/invites",
        Some(ADMIN_TOKEN),
        serde_json::json!({ "role": "reader" }),
    )
    .await;
    let secret = minted["invite"].as_str().unwrap().to_owned();
    let mut m = SoftKey::new(0x31, b"cred-m");
    let (status, out) = register(
        &c,
        &s.base,
        &mut m,
        None,
        serde_json::json!({ "invite": secret, "display": "bootstrap" }),
    )
    .await;
    assert_eq!(status, 400, "an invitee choosing a machine's name: {out}");
}

/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
    let _ = rustls::crypto::ring::default_provider().install_default();
    b.tls_certs_only(
        webpki_root_certs::TLS_SERVER_ROOT_CERTS
            .iter()
            .filter_map(|c| reqwest::Certificate::from_der(c).ok()),
    )
}

/// The file a plain start writes the first sign-in link into, wherever under `root` it is.
fn find_named(root: &std::path::Path, name: &str) -> Option<std::path::PathBuf> {
    for e in std::fs::read_dir(root).ok()?.flatten() {
        let p = e.path();
        if p.file_name().is_some_and(|n| n == name) {
            return Some(p);
        }
        if p.is_dir() {
            if let Some(f) = find_named(&p, name) {
                return Some(f);
            }
        }
    }
    None
}

/// **A plain `holger start` with passkey sign-in makes its first admin** (lane HOL-FIELD-TEST
/// P3, Gunnar's first-login path). In the field, a minimal RON with a `Passkey` method booted,
/// and nobody could ever be its admin: `holger-cli first-key` refused with "[holger-server-9]
/// this server's `group_roles` names no group with the role Admin", and nothing told the
/// operator how the first person gets in. Now the start writes a single-use first sign-in link
/// (the shop's first-login invite's shape) into a file only the box's user reads, and the
/// passkey enrolled with it is the admin: no bearer, no hand-edited `group_roles`, no
/// attestation step: the passkey is the attestation.
#[tokio::test(flavor = "multi_thread")]
async fn a_plain_start_writes_a_first_sign_in_link_and_its_passkey_is_the_admin() {
    let (grpc, http) = (hold_port(), hold_port());
    let data = tempfile::tempdir().unwrap();
    let ron = format!(
        r#"(
    repositories: [
        (ron_name: "raw", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
         ron_data_dir: Some("{data}")),
    ],
    exposed_endpoints: [
        (ron_name: "default", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")),
    ],
    storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{data}") ],
    audit: (dir: "{data}/audit", enabled: true),
    auth: Some((methods: [ Passkey(rp_id: "{rp_id}", rp_origin: "{origin}") ])),
)"#,
        data = data.path().display(),
        rp_id = RP_ID,
        origin = ORIGIN,
    );
    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(ron.as_bytes()).unwrap();
    let mut holger: Holger = read_ron_config(cfg.path()).expect("the RON must parse");
    holger.instantiate_backends().unwrap();
    wire_holger(&mut holger).unwrap();
    holger.start().unwrap();
    let base = format!("http://127.0.0.1:{http}");
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &base).await;

    let file = find_named(data.path(), "first-sign-in.url")
        .expect("a plain start with passkey sign-in writes its first sign-in link");
    {
        use std::os::unix::fs::PermissionsExt as _;
        let mode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
        assert_eq!(
            mode, 0o600,
            "the link is a credential: the box's user alone reads it"
        );
    }
    let link = std::fs::read_to_string(&file).unwrap();
    let link = link.trim();
    assert!(
        link.starts_with(&format!("{ORIGIN}/login?invite=")),
        "the link is the console's sign-in page with the invite: {}",
        link.split("invite=").next().unwrap_or_default()
    );
    let secret = link
        .split("invite=")
        .nth(1)
        .unwrap()
        .split('&')
        .next()
        .unwrap();

    let mut key = SoftKey::new(0x5a, b"cred-first-0001");
    let (status, reg) = register(
        &c,
        &base,
        &mut key,
        None,
        serde_json::json!({ "display": "owner", "invite": secret }),
    )
    .await;
    assert_eq!(
        status, 200,
        "the first sign-in link enrols a passkey: {reg}"
    );
    // ★ Single use on disk too (HOL-DOCFIND f): the used link is gone from the data root, so a
    // backup, a copy of the disk or a second reader of the box finds no credential-shaped file.
    assert!(
        !file.exists(),
        "a used first sign-in link is removed: {} is still there",
        file.display()
    );
    let (status, out) = login(&c, &base, &mut key).await;
    assert_eq!(status, 200, "the first admin logs in: {out}");
    assert_eq!(out["role"].as_str(), Some("Admin"), "{out}");
}