# 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:
```rust
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:
- `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 static `nornir-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)`:
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:
```rust
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`: `full` no longer selects `auth-webauthn`. The feature and the
optional dep **stay declared** (nothing deleted, L2) — the day a real
`AuthMethodConfig::WebAuthn` arm lands, re-pinning is one line.
- `build-space/tests/auth_ship_small.rs`: `full_tier_links_both_modern_providers` now
asserts the `full` graph contains **neither** `nornir-auth-webauthn` **nor** `openssl`
— 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.