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_method.rs

//! The FIFTH auth method: passkeys (WebAuthn), beside OIDC / OAuth2 / static
//! token / mTLS — never instead of any of them.
//!
//! holger has a complete OIDC login and no IdP in the estate to point it at, and
//! the deciding constraint is airgap: an external IdP is another service that must
//! live *inside* the bubble, be raised, sealed, upgraded and kept running, or
//! nobody logs in anywhere. A passkey needs nothing outside the box — the
//! authenticator is local and the ceremony is between the browser and the server
//! standing in front of it.
//!
//! These are the two RED-when-broken proofs the lane was told to land first.
//!
//! 1. **Additive, not a replacement.** A `Passkey` method in the config must leave
//!    the four existing methods behaving exactly as they did — the same tokens
//!    authenticate, the same CN authenticates, and the same identities resolve to
//!    the same roles. A deployment that has a real IdP keeps using OIDC with
//!    nothing changed; that is the design constraint, so it is asserted rather
//!    than asserted-by-inspection.
//!
//! 2. **An IP-literal `rp_id` is refused at boot.** A WebAuthn credential is bound
//!    to the relying-party id, which is the domain the browser sees, and a browser
//!    rejects an IP literal outright. An appliance reached by IP would therefore
//!    offer a login surface on which no key can ever be registered — a door that
//!    is open and leads nowhere. holger refuses to start instead, and says that
//!    the box must be reachable by a NAME inside the bubble.

use holger_server_lib::auth::{validate_request, AuthConfig, AuthIdentity, AuthMethodConfig, Role};

/// The relying party a well-formed airgap deployment configures: a name, not an
/// address, and an origin bound to that same name.
fn passkey_method() -> AuthMethodConfig {
    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: None,
        bootstrap_until_first_admin: true,
    }
}

/// A config carrying ALL FIVE methods plus a role policy, so the assertions below
/// are about coexistence and not about a lucky single-method path.
fn all_five() -> AuthConfig {
    AuthConfig {
        methods: vec![
            AuthMethodConfig::Oidc {
                issuer_url: "https://idp.example/realms/holger".to_owned(),
                audience: Some("holger".to_owned()),
                jwks: Some(r#"{"keys":[]}"#.to_owned()),
                jwks_path: None,
                jwks_uri: None,
            },
            AuthMethodConfig::OAuth2 {
                introspection_url: "https://idp.example/introspect".to_owned(),
                client_id: "holger".to_owned(),
                client_secret: String::new(),
            },
            AuthMethodConfig::StaticToken {
                subject: "ci".to_owned(),
                token: Some("s3cret-ci-token".to_owned()),
                token_env: None,
                token_file: None,
            },
            AuthMethodConfig::Mtls {
                ca_cert: "/etc/holger/client-ca.pem".to_owned(),
            },
            passkey_method(),
        ],
        roles: [
            ("ci".to_owned(), Role::Writer),
            ("CN=agent-07".to_owned(), Role::Reader),
        ]
        .into_iter()
        .collect(),
        group_roles: [("platform-admins".to_owned(), Role::Admin)]
            .into_iter()
            .collect(),
        default_role: Some(Role::Reader),
        ..Default::default()
    }
}

/// The same config with the passkey method removed — the "before" this change must
/// not disturb.
fn without_passkey() -> AuthConfig {
    let mut cfg = all_five();
    cfg.methods
        .retain(|m| !matches!(m, AuthMethodConfig::Passkey { .. }));
    cfg
}

// ─── Proof 1: the four existing methods keep working ─────────────────────────

/// The static bearer still authenticates, with the SAME subject, whether or not a
/// passkey method sits beside it.
///
/// RED-when-broken: a `Passkey` arm that claimed a presented bearer (rather than
/// answering "not mine" for anything that is not one of its session tokens) would
/// shadow the static-token provider and this would come back as the wrong subject
/// — or as no identity at all.
#[tokio::test]
async fn a_static_token_authenticates_the_same_with_a_passkey_method_present() {
    let with = validate_request(&all_five(), Some("s3cret-ci-token"), None)
        .await
        .expect("a valid static token is not an auth error")
        .expect("a valid static token authenticates");
    let without = validate_request(&without_passkey(), Some("s3cret-ci-token"), None)
        .await
        .expect("a valid static token is not an auth error")
        .expect("a valid static token authenticates");

    assert_eq!(with.subject, without.subject, "the subject must not move");
    assert_eq!(with.method, without.method, "the method tag must not move");
    assert_eq!(with.subject, "ci");
}

/// The mTLS client-cert CN still authenticates unchanged. mTLS matters more here
/// than anywhere else: it is one of the two credentials that keep a RENAMED or
/// RELOCATED appliance reachable, and a relocated appliance is exactly the case
/// where every registered passkey has stopped working.
#[tokio::test]
async fn an_mtls_cn_authenticates_the_same_with_a_passkey_method_present() {
    let with = validate_request(&all_five(), None, Some("CN=agent-07"))
        .await
        .expect("a client cert is not an auth error")
        .expect("a trusted client cert authenticates");
    let without = validate_request(&without_passkey(), None, Some("CN=agent-07"))
        .await
        .expect("a client cert is not an auth error")
        .expect("a trusted client cert authenticates");

    assert_eq!(with.subject, without.subject);
    assert_eq!(with.method, without.method);
}

/// A garbage bearer is still refused, and refused the SAME way. A new method must
/// not turn a rejection into an acceptance, nor an acceptance into a rejection.
#[tokio::test]
async fn an_unknown_credential_is_still_refused_with_a_passkey_method_present() {
    let with = validate_request(&all_five(), Some("not-a-real-token"), None).await;
    let without = validate_request(&without_passkey(), Some("not-a-real-token"), None).await;
    assert!(with.is_err(), "an unknown bearer must not authenticate");
    assert!(without.is_err(), "an unknown bearer must not authenticate");
}

/// **Authorization is not touched.** The role every identity resolves to is
/// byte-identical with and without the passkey method — including the group path,
/// whose maximum-over-matched-groups rule is the one a new identity shape could
/// most easily disturb.
///
/// This is the assertion behind "a deployment that has a real IdP keeps using OIDC
/// with nothing changed": the policy half of the config is provably unmoved.
#[test]
fn role_resolution_is_identical_with_and_without_the_passkey_method() {
    let with = all_five();
    let without = without_passkey();

    let identities = [
        AuthIdentity {
            subject: "ci".to_owned(),
            method: "bearer".to_owned(),
            groups: vec![],
            display: None,
        },
        AuthIdentity {
            subject: "CN=agent-07".to_owned(),
            method: "mtls".to_owned(),
            groups: vec![],
            display: None,
        },
        AuthIdentity {
            subject: "8f3a1c22-0b7e-4f1a-9c3d-000000000001".to_owned(),
            method: "oidc".to_owned(),
            groups: vec!["platform-admins".to_owned()],
            display: Some("alice".to_owned()),
        },
        // A passkey identity: an OPAQUE, enrolment-minted subject — never the
        // friendly name, which lives in `display` where a mutable claim belongs.
        // It must resolve through exactly the same ladder an OIDC identity does.
        AuthIdentity {
            subject: "passkey:9c1f7e40-2b11-4a6e-8f77-000000000002".to_owned(),
            method: "passkey".to_owned(),
            groups: vec!["platform-admins".to_owned()],
            display: Some("bo".to_owned()),
        },
        AuthIdentity {
            subject: "nobody-has-mapped-me".to_owned(),
            method: "oidc".to_owned(),
            groups: vec!["unmapped-group".to_owned()],
            display: None,
        },
    ];

    for id in &identities {
        assert_eq!(
            with.role_for_identity(id),
            without.role_for_identity(id),
            "the passkey method must not move the role of {}",
            id.subject
        );
        assert_eq!(
            with.role_for_identity_in_repo("rust-dev", id),
            without.role_for_identity_in_repo("rust-dev", id),
            "the passkey method must not move the repo-scoped role of {}",
            id.subject
        );
    }

    // And the passkey identity resolves through the GROUP ladder exactly as the
    // OIDC one does — same group, same role, different method.
    assert_eq!(with.role_for_identity(&identities[3]), Role::Admin);
    assert_eq!(
        with.role_for_identity(&identities[2]),
        with.role_for_identity(&identities[3]),
        "a passkey identity and an OIDC identity in the same group get the same role"
    );
}

// ─── Proof 2: an IP-literal rp_id is refused at boot ─────────────────────────

/// A WebAuthn credential is bound to the relying-party id, and **a browser rejects
/// an IP literal as an RP ID outright** — so a passkey method configured with one
/// can never register a single key. That is not a runtime 400 to discover on the
/// first login attempt; it is a configuration that cannot work, and holger refuses
/// it before it serves a byte, the way it already refuses an unbound OIDC issuer.
///
/// RED-when-broken: accepting it would ship an appliance whose login page renders,
/// whose ceremony starts, and whose every registration fails in the browser with a
/// message that names neither holger nor the cause.
#[test]
fn an_ip_literal_rp_id_is_refused_at_boot() {
    let cfg = AuthConfig {
        methods: vec![AuthMethodConfig::Passkey {
            rp_id: "10.0.0.7".to_owned(),
            rp_origin: "https://10.0.0.7".to_owned(),
            rp_name: "holger".to_owned(),
            session_key_file: None,
            bootstrap_until_first_admin: true,
        }],
        ..Default::default()
    };
    let err = holger_server_lib::auth::refuse_unusable_passkey_rp_id(&cfg)
        .expect_err("an IP-literal rp_id must be refused at boot");
    let msg = format!("{err:#}");
    assert!(
        msg.contains("10.0.0.7"),
        "the refusal must name the offending rp_id, got: {msg}"
    );
    assert!(
        msg.to_lowercase().contains("name"),
        "the refusal must tell the operator to reach the appliance by NAME, got: {msg}"
    );
}

/// An IPv6 literal is the same mistake and gets the same refusal — the check is on
/// "is this an address", not on "does it contain dots".
#[test]
fn an_ipv6_literal_rp_id_is_refused_at_boot() {
    let cfg = AuthConfig {
        methods: vec![AuthMethodConfig::Passkey {
            rp_id: "fd00::1".to_owned(),
            rp_origin: "https://[fd00::1]".to_owned(),
            rp_name: "holger".to_owned(),
            session_key_file: None,
            bootstrap_until_first_admin: true,
        }],
        ..Default::default()
    };
    assert!(
        holger_server_lib::auth::refuse_unusable_passkey_rp_id(&cfg).is_err(),
        "an IPv6-literal rp_id must be refused at boot"
    );
}

/// An origin whose host is NOT the `rp_id` (nor a subdomain of it) is refused too.
///
/// `rp_id` is the phishing boundary — it is what the authenticator scopes the
/// credential to — and the origin is what the assertion is checked against. If the
/// two can disagree, the check that is supposed to bind a credential to this
/// server is checking something else.
#[test]
fn an_origin_that_does_not_belong_to_the_rp_id_is_refused_at_boot() {
    let cfg = AuthConfig {
        methods: vec![AuthMethodConfig::Passkey {
            rp_id: "holger.lab.internal".to_owned(),
            rp_origin: "https://console.example.com".to_owned(),
            rp_name: "holger".to_owned(),
            session_key_file: None,
            bootstrap_until_first_admin: true,
        }],
        ..Default::default()
    };
    assert!(
        holger_server_lib::auth::refuse_unusable_passkey_rp_id(&cfg).is_err(),
        "an origin outside the rp_id must be refused at boot"
    );
}

/// The well-formed deployment boots, and so does every config that names no
/// passkey method at all — the check must be silent for the deployments that
/// existed before it.
#[test]
fn a_well_formed_relying_party_and_a_config_without_passkeys_both_boot() {
    let good = AuthConfig {
        methods: vec![passkey_method()],
        ..Default::default()
    };
    assert!(holger_server_lib::auth::refuse_unusable_passkey_rp_id(&good).is_ok());

    // A subdomain origin under the rp_id is legitimate WebAuthn and must pass.
    let subdomain = AuthConfig {
        methods: vec![AuthMethodConfig::Passkey {
            rp_id: "lab.internal".to_owned(),
            rp_origin: "https://holger.lab.internal".to_owned(),
            rp_name: "holger".to_owned(),
            session_key_file: None,
            bootstrap_until_first_admin: true,
        }],
        ..Default::default()
    };
    assert!(holger_server_lib::auth::refuse_unusable_passkey_rp_id(&subdomain).is_ok());

    assert!(holger_server_lib::auth::refuse_unusable_passkey_rp_id(&without_passkey()).is_ok());
    assert!(holger_server_lib::auth::refuse_unusable_passkey_rp_id(&AuthConfig::default()).is_ok());
}