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