Holger auth — pluggable, feature-gated providers (task #57)
Holger gates its gRPC + HTTP write/promote endpoints with MODERN authentication. Authentication (who are you) is split into pluggable providers, one per method, each in its own feature-gated sub-crate; authorization (what may you do) stays as declarative RBAC policy in the RON config.
The shared seam (edda / nornir-service)
The ONE provider trait lives in nornir-service (edda) so every nordisk server reuses it — not a holger-local twin (Law #1 / #5). Behind the auth-provider feature:
pub struct Principal { pub subject: String, pub scopes: Vec<String>, pub method: String }
pub enum ProviderError { Invalid /* authoritative reject */, Unavailable /* try next */ }
#[async_trait] // object-safe: async desugars to boxed futures
pub trait AuthProvider: Send + Sync {
fn name(&self) -> &str;
async fn verify_bearer(&self, token: &str) -> Result<Option<Principal>, ProviderError>;
}
pub async fn verify_any(providers: &[Box<dyn AuthProvider>], token: &str)
-> Result<Option<Principal>, ProviderError>; // first Ok(Some) wins; hard Invalid is authoritative
verify_bearer returns Ok(Some) (authenticated), Ok(None) ("not mine — try the next provider"), Err(Invalid) (this provider's shape but rejected — authoritative), or Err(Unavailable) (offline etc. — dispatcher moves on).
The trait is object-safe (Box<dyn AuthProvider>) on purpose: a future WasmAuthProvider (wasmtime-loaded, for an operator's proprietary SSO dropped in without a rebuild) can slot in behind the same trait. We deliberately do NOT make the default auth path wasm — auth is hot + security-critical, so compile-time feature-gated sub-crates are safer/faster/smaller. Wasm is a documented future seam only.
Provider sub-crates
| Crate | Feature | Method | Deps (the point of the split) |
nornir-auth-bearer (edda) |
auth-bearer |
Static Bearer token / API-key (M2M: CI, agents) | tiny — reuses nornir_service::{resolve_token, token_matches}; NO crypto/HTTP |
nornir-auth-oidc (edda) |
auth-oidc |
OIDC / OAuth2: JWT-vs-JWKS offline (airgap) + /userinfo online + device-code CLI login |
jsonwebtoken (JWKS), optional reqwest (online) |
nornir-auth-mtls (edda) |
auth-mtls |
mTLS (client-cert leaf CN → identity), transport-level | tiny — NO crypto/HTTP (handshake + CA trust live in the transport); maps the extracted CN via the SAME AuthProvider seam (verify_client_cert) |
nornir-auth-oauth2 (edda) |
auth-oauth2 |
OAuth 2.0/2.1 beyond OIDC's JWT-vs-JWKS: RFC 7662 token introspection + client-credentials | reqwest under online (introspection is an HTTP call) — dispatched via the AuthMethodConfig::OAuth2 variant |
nornir-auth-webauthn (edda) |
auth-webauthn |
WebAuthn / FIDO2 / passkeys (passwordless) | NOT pinned by any tier (2026-07-22) — the feature + optional dep stay declared, but nothing selects them. It was linked into full and never dispatched; see §WebAuthn below for the decision and its measured cost |
The provider crates live in edda (
edda/crates/nornir-auth-{bearer,oidc}), beside thenornir_service::AuthProvidertrait — MOVED out of holger's tree so korp + dwarves reuse the SAME providers (reuse-law / move=delete). holger'sserver/libconsumes them by path and remains the REFERENCE full consumer. The shared client-side login (<app> login) isnornir-auth-client(edda). Seeworkspace_nordisk/.nornir/unified-auth-edda.mdfor the whole shared stack.
nornir-auth-oidc covers the modern enterprise IdPs through the ONE OIDC protocol: Microsoft Entra ID (the ADFS successor), Okta, Auth0, Google, Keycloak (the reference test IdP — self-hostable OSS), Zitadel. mTLS (leaf-cert CN) is now the shared edda nornir-auth-mtls provider (MOVED out of server/lib/auth.rs — move=delete) — transport-level, not a bearer provider, dispatched via nornir_service::verify_any_cert from the extracted CN. It links no crypto/HTTP, so every tier that terminates client certs pins it cheaply.
Priority & future providers (do NOT build yet)
Dispatched now (a live AuthMethodConfig variant, all shared edda sub-crates): OIDC + Bearer + mTLS + OAuth2. WebAuthn is declared but pinned by no tier — it was linked into full and never dispatched; the link was dropped (§WebAuthn). (There is no separate API-key provider: the static-token / API-key path IS nornir-auth-bearer.) The trait + feature-gating leave the layout open to slot the following in later as sibling sub-crates, with zero change to the dispatch:
nornir-auth-saml— SAML 2.0 (Okta/Ping/ADFS estates that require it) — future.nornir-auth-webauthn— passwordless / passkeys (FIDO2): crate declared, linked by no tier; passkeys arrive through the IdP as OIDC instead (§WebAuthn).nornir-auth-apikey— hashed/rotating API keys beyond the static bearer — future (today's API-key path is the staticnornir-auth-bearer).- SCIM (provisioning) — later.
Full reference: workspace_nordisk/.nornir/auth-sso-providers.md.
Build-thing tier pinning ("ship small")
The mode features pin the provider set per tier (mutually-exclusive deployment modes in server/lib/Cargo.toml):
| Tier | Mode feature | Providers linked |
embedded (bin holger-embedded) |
embedded = ["auth-bearer", "auth-mtls"] |
bearer + mTLS (both tiny) |
mini (bin holger-mikro) |
mini = ["auth-bearer", "auth-mtls"] |
bearer + mTLS (both tiny) |
full (bin holger-full) |
full = ["federation", "auth-bearer", "auth-oidc", "auth-oauth2", "auth-mtls"] |
bearer + OIDC + OAuth2 + mTLS — every one of them DISPATCHED |
default (dev/test) = mini + online + auth-bearer + auth-oidc + auth-oauth2 + auth-mtls so the dispatched auth surface is exercised by cargo test. online propagates to the OIDC crate ONLY when it is pinned (nornir-auth-oidc?/online), so the airgap tier links no HTTP egress. auth-mtls is pinned in EVERY tier (holger always accepts client certs) — it carries no crypto/HTTP closure, so it never violates ship-small.
Proof (build-space/tests/auth_ship_small.rs): cargo tree of the lean (mini) feature set contains nornir-auth-bearer + nornir-auth-mtls but NOT nornir-auth-oidc / jsonwebtoken / oauth2 / webauthn / openssl; the full set links both modern providers (OIDC + OAuth2) and — since 2026-07-22 — asserts that it links NEITHER nornir-auth-webauthn NOR openssl. RED-when-broken if the OIDC/OAuth2 closure ever leaks into the lean tier.
Dispatch + RBAC
server/lib/auth.rs::validate_request(config, bearer, client_cn):
- If no methods configured ⇒
Ok(None)(open access — reads are anonymous anyway; this keeps writes open only when explicitly unconfigured). - Build the enabled providers from the config methods (feature-gated) — ONE
Vec<Box<dyn AuthProvider>>mixing the bearer providers (OIDC / static-token) and the mTLS provider. Dispatch the presented bearer viaverify_any; first to authenticate wins →AuthIdentity { subject, method }. - Else dispatch the extracted client-cert CN via
verify_any_cert(the mTLS provider claims it; the bearer providers default to "not mine"). - Else
Err(Unauthorized).
RBAC (unchanged): AuthConfig.roles / default_role / per-repo repo_roles map the resolved subject → Role (Reader < Writer < Admin). The gRPC authorize_write / authorize_admin and the HTTP authorize_http_write gates require Writer for writes, Admin for promote/delete — fail-closed once any policy is configured. Anonymous read stays open unless the config gates it.
A method configured whose provider was NOT pinned into the build authenticates nothing (fail-closed) and AuthConfig::warn_unpinned_providers() shouts at boot.
WebAuthn — the link was dropped, and why (2026-07-22)
nornir-auth-webauthn sat in the full tier's feature set: linked, never dispatched. validate_request has no AuthMethodConfig::WebAuthn arm, so no configuration could route a request to it. It was pure weight. Two ways out — wire the dispatch, or drop the link. The link was dropped.
Why not wire the dispatch
WebAuthn is not a credential; it is a ceremony. Registration and authentication are each two stateful round-trips — the server mints a challenge, the authenticator signs it, the server verifies against per-user credential state it stored earlier — and the whole thing is bound to a relying-party ID and browser origin.
holger's auth seam is the opposite shape by design:
validate_request(config, bearer: Option<&str>, client_cn: Option<&str>) -> AuthIdentity
One stateless credential, per request, over gRPC/HTTP2. There is no session store to hold a challenge, no per-user credential registry, and — the decisive part — no browser login surface anywhere in holger: the operator UI (bin holger-server-ui) is an egui app that talks gRPC with a bearer, and the clients are pip / cargo / docker / helm. Dispatching WebAuthn would mean inventing a session layer and an HTTP login flow that nothing in the product asks for.
What it cost while it sat there
Measured by resolving both graphs with cargo tree (--no-default-features --features full, with and without auth-webauthn):
crates in the full graph |
openssl mentions |
|
with auth-webauthn |
334 | 6 |
| without | 320 | 0 |
+14 crates, including the entire native openssl stack (openssl, openssl-sys, openssl-macros — a C toolchain and a system libssl link requirement, with a long CVE history) and MPL-2.0 webauthn-rs + webauthn-rs-core + webauthn-rs-proto + webauthn-attestation-ca. In an airgap-facing artifact registry whose TLS is otherwise rustls/ring all the way down. For zero reachable functionality.
Passkeys are not lost — they belong at the IdP
Entra ID, Okta, Auth0, Keycloak and Zitadel all implement WebAuthn/FIDO2. holger already verifies the OIDC JWT they mint, offline against JWKS, through nornir-auth-oidc. A user who unlocks their laptop with a passkey to log into Keycloak and then calls holger is using passkey authentication, end to end, with no WebAuthn code in holger. That is the correct architecture for a service that is not a browser app: authenticate humans at the identity provider, consume the assertion as a token.
What was actually changed
server/lib/Cargo.toml:fullno longer selectsauth-webauthn. The feature and the optional dep stay declared (nothing deleted, L2) — the day a realAuthMethodConfig::WebAuthnarm lands, re-pinning is one line.build-space/tests/auth_ship_small.rs:full_tier_links_both_modern_providersnow asserts thefullgraph contains neithernornir-auth-webauthnnoropenssl— a stronger gate than the one it replaced, and RED-when-broken if anyone re-pins the provider without landing the dispatch (or drags openssl in by another route).
If it should come back
Land these first, in this order: (1) an AuthMethodConfig::WebAuthn variant and a validate_request arm; (2) a credential/challenge store (redb is the natural home — it is operational state, not policy, so it belongs there and not in RON); (3) an HTTP login surface with a configured relying-party origin. Then re-pin the feature and flip the assertion back. Until all three exist, linking the crate is paying for nothing.