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}");
}