crates/nornir-auth-policy/tests/passkey_dispatch.rs
//! The fifth method actually DISPATCHES — and does not disturb the four.
//!
//! `AuthMethodConfig::WebAuthn` was declared in this family once and left
//! undispatched, on the reasoning that a two-round-trip ceremony has nowhere to
//! run inside `validate_request(config, bearer, client_cert_cn)`, which sees one
//! stateless credential per request. The reasoning was right about the seam and
//! wrong about the conclusion: **the ceremony is not the credential.** It runs on
//! the server's own door and mints a MAC'd session token, and the token is an
//! ordinary bearer. These tests are the proof that the dispatch is real, because a
//! configured method that authenticates nobody is the failure mode this whole
//! family already fell into once.
#![cfg(all(feature = "auth-passkey", feature = "auth-bearer"))]
use nornir_auth_policy::{validate_request, AuthConfig, AuthMethodConfig, Role};
use nornir_auth_webauthn::pure::{now_secs, SessionClaims, SessionKey};
/// Write a session key to a temp file the way an appliance would, and hand back
/// both the file and the key so a test can mint a token the server will accept.
fn session_key_file(dir: &std::path::Path, bytes: [u8; 32]) -> (String, SessionKey) {
let path = dir.join("passkey-session.key");
std::fs::write(&path, bytes).expect("write the session key");
(path.to_string_lossy().into_owned(), SessionKey::new(bytes))
}
fn claims(sub: &str, groups: &[&str]) -> SessionClaims {
SessionClaims {
sub: sub.to_owned(),
groups: groups.iter().map(|g| (*g).to_owned()).collect(),
display: Some("bo".to_owned()),
exp: now_secs() + 3600,
auth_time: now_secs(),
uv: true,
epoch: 0,
}
}
fn config(key_path: &str) -> AuthConfig {
AuthConfig {
methods: vec![
AuthMethodConfig::StaticToken {
subject: "ci".to_owned(),
token: Some("s3cret-ci-token".to_owned()),
token_env: None,
token_file: None,
},
AuthMethodConfig::Passkey {
rp_id: "holger.lab.internal".to_owned(),
rp_origin: "https://holger.lab.internal".to_owned(),
rp_name: "holger".to_owned(),
session_key_file: Some(key_path.to_owned()),
bootstrap_until_first_admin: true,
},
],
group_roles: [("platform-admins".to_owned(), Role::Admin)]
.into_iter()
.collect(),
..Default::default()
}
}
/// A session token minted by a verified ceremony authenticates, carrying the
/// OPAQUE subject, the `passkey` method tag, the groups the enrolment recorded,
/// and the display name for the audit line.
#[tokio::test]
async fn a_session_token_from_a_ceremony_authenticates() {
let tmp = tempfile::tempdir().unwrap();
let (path, key) = session_key_file(tmp.path(), [7u8; 32]);
let token = key.mint(&claims("passkey:9c1f7e40-2b11-4a6e-8f77-0002", &["platform-admins"]));
let id = validate_request(&config(&path), Some(&token), None)
.await
.expect("a genuine session token is not an auth error")
.expect("a genuine session token authenticates");
assert_eq!(id.subject, "passkey:9c1f7e40-2b11-4a6e-8f77-0002");
assert_eq!(id.method, "passkey");
assert_eq!(id.groups, vec!["platform-admins".to_owned()]);
assert_eq!(id.display.as_deref(), Some("bo"));
// And it resolves through the SAME group ladder an OIDC identity walks — the
// property that makes "swap the IdP back in later" a config edit.
assert_eq!(config(&path).role_for_identity(&id), Role::Admin);
}
/// The passkey provider does not shadow the static-token provider beside it. A
/// bearer that is not one of our session tokens must be "not mine", not a refusal
/// — otherwise adding this method would silently break every machine credential.
#[tokio::test]
async fn the_passkey_provider_does_not_shadow_the_static_token() {
let tmp = tempfile::tempdir().unwrap();
let (path, _key) = session_key_file(tmp.path(), [7u8; 32]);
let id = validate_request(&config(&path), Some("s3cret-ci-token"), None)
.await
.expect("the static token is not an auth error")
.expect("the static token still authenticates");
assert_eq!(id.subject, "ci");
assert_eq!(id.method, "bearer");
}
/// A token minted with ANOTHER key does not authenticate. This is the whole
/// security of the session plane: the MAC is what says the ceremony happened.
#[tokio::test]
async fn a_token_minted_with_another_key_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let (path, _key) = session_key_file(tmp.path(), [7u8; 32]);
let forged = SessionKey::new([9u8; 32]).mint(&claims("passkey:impostor", &["platform-admins"]));
assert!(
validate_request(&config(&path), Some(&forged), None)
.await
.is_err(),
"a token minted with a key this server does not hold must not authenticate"
);
}
/// An expired token is refused even though its MAC is perfect — the session plane
/// is bounded in time, not just in authenticity.
#[tokio::test]
async fn an_expired_token_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let (path, key) = session_key_file(tmp.path(), [7u8; 32]);
let mut c = claims("passkey:9c1f7e40", &[]);
c.exp = now_secs() - 1;
assert!(validate_request(&config(&path), Some(&key.mint(&c)), None)
.await
.is_err());
}
/// **Rotating the key file is the revocation hammer.** Every outstanding token is
/// MAC'd with it, so replacing it voids all of them at once — deliberately the one
/// mechanism, rather than a second epoch counter beside it that does the same job
/// and can disagree.
#[tokio::test]
async fn rotating_the_key_file_voids_every_outstanding_token() {
let tmp = tempfile::tempdir().unwrap();
let (path, key) = session_key_file(tmp.path(), [7u8; 32]);
let token = key.mint(&claims("passkey:9c1f7e40", &[]));
assert!(
validate_request(&config(&path), Some(&token), None)
.await
.unwrap()
.is_some(),
"good before the rotation"
);
std::fs::write(&path, [8u8; 32]).unwrap();
assert!(
validate_request(&config(&path), Some(&token), None)
.await
.is_err(),
"every token minted under the old key must be dead after a rotation"
);
}
/// An unresolvable key file DISABLES the method rather than opening it: the
/// provider is dropped, so it authenticates nobody and cannot shadow a provider
/// that would have matched. Fail-closed, the same rule the static-bearer provider
/// follows for an empty secret.
#[tokio::test]
async fn an_unresolvable_key_file_disables_the_method_without_disabling_the_others() {
let cfg = config("/nonexistent/passkey-session.key");
assert!(
validate_request(&cfg, Some("anything-at-all"), None)
.await
.is_err(),
"a passkey method with no key must authenticate nobody"
);
// ...and the static token beside it is untouched.
let id = validate_request(&cfg, Some("s3cret-ci-token"), None)
.await
.unwrap()
.expect("the static token still authenticates");
assert_eq!(id.subject, "ci");
}
/// A key file of the wrong length is a mistake, not a shorter key — truncating or
/// padding it would authenticate tokens under a key nobody chose.
#[tokio::test]
async fn a_wrong_length_key_file_disables_the_method() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("short.key");
std::fs::write(&path, b"too short").unwrap();
let cfg = config(&path.to_string_lossy());
assert!(validate_request(&cfg, Some("anything"), None).await.is_err());
}
/// The variant round-trips through RON, which is how an operator actually writes
/// it — and the two flags that have `#[serde(default)]` may be omitted, so a
/// minimal config is short enough to get right by hand.
#[test]
fn the_passkey_method_round_trips_through_ron() {
let ron = r#"(
methods: [
Passkey(
rp_id: "holger.lab.internal",
rp_origin: "https://holger.lab.internal",
),
],
)"#;
let cfg: AuthConfig = ron::from_str(ron).expect("a minimal passkey method parses");
match &cfg.methods[0] {
AuthMethodConfig::Passkey {
rp_id,
rp_origin,
rp_name,
session_key_file,
bootstrap_until_first_admin,
} => {
assert_eq!(rp_id, "holger.lab.internal");
assert_eq!(rp_origin, "https://holger.lab.internal");
assert_eq!(rp_name, "", "an omitted rp_name falls back at build time");
assert!(session_key_file.is_none());
assert!(
*bootstrap_until_first_admin,
"the bootstrap retirement must default ON — a break-glass credential that \
keeps working after a real one exists is the one that leaks"
);
}
other => panic!("expected a Passkey method, got {other:?}"),
}
// And it survives a serialize → parse round trip unchanged.
let text = ron::to_string(&cfg).unwrap();
let back: AuthConfig = ron::from_str(&text).unwrap();
assert_eq!(back.methods.len(), 1);
}
/// A config written BEFORE this variant existed still parses, unchanged. The
/// promise that a deployment with a real IdP is undisturbed has to hold at the
/// config layer too, not only at the role layer.
#[test]
fn a_config_written_before_passkeys_existed_still_parses() {
let ron = r#"(
methods: [
Oidc(issuer_url: "https://idp.example/realms/holger", audience: Some("holger")),
Mtls(ca_cert: "/etc/holger/client-ca.pem"),
],
roles: {"ci": Writer},
default_role: Some(Reader),
)"#;
let cfg: AuthConfig = ron::from_str(ron).expect("the pre-passkey config must still parse");
assert_eq!(cfg.methods.len(), 2);
assert!(cfg.rbac_enabled());
}