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

nordisk/holger

.nornir/auth-providers.md

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 the nornir_service::AuthProvider trait — MOVED out of holger's tree so korp + dwarves reuse the SAME providers (reuse-law / move=delete). holger's server/lib consumes them by path and remains the REFERENCE full consumer. The shared client-side login (<app> login) is nornir-auth-client (edda). See workspace_nordisk/.nornir/unified-auth-edda.md for 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:

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):

  1. If no methods configured ⇒ Ok(None) (open access — reads are anonymous anyway; this keeps writes open only when explicitly unconfigured).
  2. 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 via verify_any; first to authenticate wins → AuthIdentity { subject, method }.
  3. Else dispatch the extracted client-cert CN via verify_any_cert (the mTLS provider claims it; the bearer providers default to "not mine").
  4. 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

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.