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

nordisk/edda

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