crates/nornir-service/src/lib.rs
//! # nornir-service
//!
//! The **shared tonic gRPC service scaffold** for the nordisk servers. Three
//! near-identical hand-rolled tonic bootstraps (`nornir-server`,
//! `dwarves-server`, `korp-server`) each re-implemented the same
//! `authorization: Bearer <token>` interceptor + token compare. This leaf crate
//! holds the ONE canonical copy so an app **consumes** it (Law #1) rather than
//! carrying a twin.
//!
//! ## What it provides today
//!
//! * [`token_matches`] — the constant-time bearer-token comparison (bug #6 in
//! nornir): an empty supplied token always fails, and a non-empty one is
//! compared in time that does not depend on the position of the first
//! differing byte, closing the timing side-channel a plain `!=` opens.
//! * [`auth_interceptor`] — the tonic `with_interceptor` factory that enforces
//! `authorization: Bearer <token>` on a service. An **empty configured
//! token** means *open* (dev / single-workspace `dwarves` Phase-0); a
//! non-empty one is required and compared constant-time. nornir-server pins
//! its token to ≥16 chars at boot, so it is always in the strict branch —
//! behaviour is byte-identical to the code this replaces.
//!
//! * [`serve`] — the `Server::builder() … .serve(addr)` bootstrap tail all
//! three servers duplicated. The caller builds each service (each wrapped in
//! [`auth_interceptor`]), collects them into a [`tonic::service::Routes`], and
//! hands the routes + bind addr here; this drives the one canonical
//! `tonic::transport::Server` serve loop. Per-service tuning (e.g. a larger
//! `max_encoding_message_size`) stays on the service *before* it is added to
//! the routes, so nothing app-specific is lost.
//! * [`workspace_header`] — the per-request workspace-routing convention. Both
//! nornir (`nornir-workspace`) and dwarves (`dwarves-workspace`) route a
//! request to a served workspace by a metadata header; this is the ONE
//! extractor (get → utf-8 → non-empty) they each duplicated inline. The header
//! *key* stays the caller's (the two apps use different names), the extraction
//! is shared.
//!
//! * [`resolve_token`] — the server-side bearer-secret **resolution ladder**
//! (`--flag` > env var > `<home>/.<app>/token` file > open) that
//! nornir-server / dwarves-server / korp-server each hand-rolled. The only
//! things that differed between the three copies were two literals — the env
//! var name and the token-file path — so they are the parameters; the ladder
//! (and its "empty value falls through, never *set to empty*" rule) is shared.
//! The min-length / required-token *policy* stays in each conductor (see the
//! fn docs), so only the copied mechanism moves here.
//!
//! The wire-facing interceptor + compare — the part that was actually copied —
//! is shared, and so now is the resolution ladder that feeds it.
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use tonic::service::Routes;
use tonic::transport::Server;
use tonic::{Request, Status};
/// Client-side bearer stamping — the ONE outbound `authorization: Bearer <token>`
/// builder the leaf embed crates + nornir's client paths consume instead of each
/// hand-rolling `format!("Bearer …").parse().insert(…)`. See [`client::bearer`].
pub mod client;
/// **The `tonic` this crate's public API speaks** — re-exported so a consumer can
/// NAME the types it must construct.
///
/// [`auth_interceptor`] returns `impl Fn(Request<()>) -> Result<Request<()>, Status>`,
/// and until this existed a caller had no way to spell that `Request`: korp declares
/// `tonic = "0.13"` for its own server plus `tonic-flight = { package = "tonic",
/// version = "0.14" }`, so a plain `use tonic::Request` in korp resolves to 0.13 and
/// the interceptor's is 0.14. The compiler's own words for it were "there are multiple
/// different versions of crate `tonic` in the dependency graph".
///
/// It cost more than a confusing error. The mismatch was inside korp's
/// `#[cfg(test)]` module, so `cargo check --features server` passed while
/// `cargo test --features server` could not build the lib test target at all — and
/// `korp/src/control.rs`'s `assets_tests`, which is gated on that same feature, has
/// therefore never run. A guard nobody can compile is indistinguishable from a guard
/// that passes.
///
/// Re-exporting is the by-construction fix (LAW 5): the version a consumer builds a
/// `Request` from is now, by definition, the version this crate's signature takes. A
/// caller that writes `nornir_service::tonic::Request` cannot pick the wrong one, and
/// there is no second place for the two to drift apart.
pub use tonic;
#[cfg(feature = "auth-provider")]
pub use provider::{verify_any, verify_any_cert, AuthProvider, ProviderError, Principal};
/// The pluggable, object-safe auth-provider seam. A server's interceptor holds a
/// set of `Box<dyn AuthProvider>` — one per MODERN auth method its build pinned
/// (OIDC / OAuth2 / static Bearer) — and dispatches a presented bearer credential
/// to them in order. Each provider lives in its OWN feature-gated sub-crate
/// (nornir-auth-{oidc,bearer} in edda) so a build that pins only one provider
/// links only that provider's dependency closure ("ship small"). The trait is
/// object-safe (async via `tonic::async_trait`, so each method desugars to a
/// boxed future) — a future `WasmAuthProvider` (wasmtime-loaded, for an operator's
/// proprietary SSO dropped in without a rebuild) can slot in behind the same
/// `dyn AuthProvider` with no change here.
#[cfg(feature = "auth-provider")]
pub mod provider {
use tonic::async_trait;
/// A verified caller. `subject` is the stable identity a server's RBAC layer
/// keys on (an OIDC `sub`, an OAuth2 subject, or a static-token principal
/// name); `scopes` are the coarse grants the credential carried (provider-
/// native strings — e.g. `read`/`write`/`admin` or an OAuth2 `scope` list);
/// `method` names the provider that verified it (`oidc`/`oauth2`/`bearer`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
pub subject: String,
pub scopes: Vec<String>,
pub method: String,
/// **The credential's GROUP grants** — an OIDC token's `groups` and
/// `roles` claims, unioned. Empty for credentials that carry none
/// (static bearer, API key, mTLS CN).
///
/// Distinct from [`scopes`](Self::scopes) on purpose and not folded into
/// it: a scope is what the CLIENT was authorized to ask for and a group
/// is who the PERSON is. A group named `admin` and a scope named `admin`
/// mean different things, and a policy that could not tell them apart
/// would be one confused config away from a privilege grant.
pub groups: Vec<String>,
/// **The human name for an audit line** — `preferred_username`, else
/// `email`, else `None`. Display only; it authorizes nothing, because a
/// claim the user can change must never decide privilege.
pub display: Option<String>,
}
impl Principal {
/// A principal with no scopes yet (the RBAC layer maps `subject`→role).
pub fn new(subject: impl Into<String>, method: impl Into<String>) -> Self {
Self {
subject: subject.into(),
scopes: Vec::new(),
method: method.into(),
groups: Vec::new(),
display: None,
}
}
/// Builder: attach the credential's granted scopes.
pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
self.scopes = scopes;
self
}
/// Builder: attach the credential's group/role claims.
pub fn with_groups(mut self, groups: Vec<String>) -> Self {
self.groups = groups;
self
}
/// Builder: attach the human-readable name for audit lines.
pub fn with_display(mut self, display: Option<String>) -> Self {
self.display = display;
self
}
/// Is this identity in `group`?
pub fn in_group(&self, group: &str) -> bool {
self.groups.iter().any(|g| g == group)
}
/// Was this scope granted?
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope)
}
}
/// Why a provider did not authenticate a credential.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderError {
/// The credential is definitively invalid for this provider (bad
/// signature / expired / wrong issuer / wrong secret). Authoritative:
/// the dispatcher does NOT keep trying other providers on a hard reject
/// once a provider claims the credential.
Invalid,
/// This provider cannot serve the request right now (e.g. offline and no
/// pre-provisioned keys). Non-authoritative: the dispatcher moves on.
Unavailable,
}
/// Object-safe pluggable auth provider: verify a presented Bearer credential
/// into a [`Principal`]. Implemented by the per-method sub-crates and
/// dispatched by a server's interceptor via [`verify_any`].
#[async_trait]
pub trait AuthProvider: Send + Sync {
/// Provider tag — also the [`Principal::method`] set on success.
fn name(&self) -> &str;
/// Verify a bearer credential (the value after `Bearer `).
///
/// * `Ok(Some(principal))` — authenticated.
/// * `Ok(None)` — "not mine": the credential is not one this provider
/// recognizes (e.g. an opaque token handed to a JWT-only provider);
/// the dispatcher tries the next provider.
/// * `Err(ProviderError::Invalid)` — the credential IS this provider's
/// shape but failed verification (authoritative reject).
/// * `Err(ProviderError::Unavailable)` — provider can't serve now.
async fn verify_bearer(&self, token: &str) -> Result<Option<Principal>, ProviderError>;
/// Verify a TRANSPORT-authenticated client-certificate identity (mTLS).
///
/// `cn` is the leaf client-cert Common Name ALREADY extracted by the TLS
/// layer after a successful mutual-TLS handshake — cryptographic trust
/// (the CA chain) was established by the transport, not here. A cert
/// provider (`nornir-auth-mtls`) maps that CN to a [`Principal`]; every
/// bearer-only provider ignores it. Same result contract as
/// [`verify_bearer`](Self::verify_bearer):
///
/// * `Ok(Some(principal))` — this provider claims the client-cert identity.
/// * `Ok(None)` — "not mine" (the default: a bearer provider does not
/// speak client certs); the dispatcher tries the next provider.
/// * `Err(ProviderError::Invalid)` — recognized but rejected (authoritative).
/// * `Err(ProviderError::Unavailable)` — provider can't serve now.
///
/// Defaulted so the existing bearer providers (oidc/bearer) need no change
/// and the trait stays object-safe (a boxed future via `async_trait`).
async fn verify_client_cert(&self, _cn: &str) -> Result<Option<Principal>, ProviderError> {
Ok(None)
}
/// Verify a **SAML 2.0 assertion / response** (the enterprise-SSO credential
/// shape) into a [`Principal`]. `assertion_xml` is the SAML `<Response>` (or
/// bare signed `<Assertion>`) XML delivered by the HTTP-POST binding — the
/// caller/transport has already base64-decoded it. A SAML provider
/// (`nornir-auth-saml`) cryptographically verifies the XML-DSIG signature
/// against the configured IdP certificate, checks the conditions
/// (audience / NotBefore / NotOnOrAfter), and maps the subject `NameID` to a
/// [`Principal{ method: "saml" }`]. Every non-SAML provider ignores it. Same
/// result contract as [`verify_bearer`](Self::verify_bearer):
///
/// * `Ok(Some(principal))` — the assertion verified.
/// * `Ok(None)` — "not mine" (the default: a bearer/cert provider does not
/// speak SAML); the dispatcher tries the next provider.
/// * `Err(ProviderError::Invalid)` — a SAML assertion that FAILED
/// verification (bad signature / expired / wrong audience) — authoritative.
/// * `Err(ProviderError::Unavailable)` — provider can't serve now (e.g. built
/// without the heavy XML-DSIG backend).
///
/// Defaulted so the existing bearer/cert providers need no change and the
/// trait stays object-safe. Added by the SAML seam (nornir-service 0.1.5).
async fn verify_saml_assertion(
&self,
_assertion_xml: &str,
) -> Result<Option<Principal>, ProviderError> {
Ok(None)
}
/// Verify a **WebAuthn / FIDO2 assertion** (the passwordless / passkey
/// credential shape) into a [`Principal`]. The WebAuthn authentication
/// ceremony is STATEFUL: the server starts it (holding the random challenge
/// + the allowed-credential list), the authenticator signs it, and the
/// server finishes it by verifying the signed assertion AGAINST that held
/// state. So this method takes BOTH serialized halves:
///
/// * `response_json` — the authenticator's signed assertion (a serialized
/// `PublicKeyCredential`, delivered from the browser's
/// `navigator.credentials.get`),
/// * `state_json` — the server-held ceremony state (the serialized challenge
/// / allow-list the matching start-authentication call produced, plus the
/// subject the ceremony authenticates).
///
/// A WebAuthn provider (`nornir-auth-webauthn`) cryptographically verifies
/// the assertion signature, the challenge match, the origin (RP), and the
/// user-presence/verification flags, then maps the ceremony's subject to a
/// [`Principal{ method: "webauthn" }`]. Every non-WebAuthn provider ignores
/// it. Same result contract as [`verify_bearer`](Self::verify_bearer):
///
/// * `Ok(Some(principal))` — the assertion verified.
/// * `Ok(None)` — "not mine" (the default: a bearer/cert/saml provider does
/// not speak WebAuthn), or nothing was presented; the dispatcher tries the
/// next provider.
/// * `Err(ProviderError::Invalid)` — a WebAuthn assertion that FAILED
/// verification (bad signature / wrong challenge / wrong origin / malformed)
/// — authoritative.
/// * `Err(ProviderError::Unavailable)` — provider can't serve now (e.g. built
/// without the heavy `openssl`-backed WebAuthn backend, or unusable state).
///
/// Defaulted so the existing bearer/cert/saml providers need no change and
/// the trait stays object-safe. Added by the WebAuthn seam (nornir-service
/// 0.1.6).
async fn verify_webauthn_assertion(
&self,
_response_json: &str,
_state_json: &str,
) -> Result<Option<Principal>, ProviderError> {
Ok(None)
}
}
/// Dispatch a bearer credential across an ordered provider set: the first
/// `Ok(Some)` wins. A provider's `Err(Invalid)` is authoritative and returned
/// immediately (a credential recognized-but-rejected must NOT be retried into
/// an accept by a laxer provider). `Ok(None)`/`Err(Unavailable)` fall through
/// to the next provider; if none accept, `Ok(None)` (no provider authenticated
/// it — the caller decides whether that is "open" or "denied").
pub async fn verify_any(
providers: &[Box<dyn AuthProvider>],
token: &str,
) -> Result<Option<Principal>, ProviderError> {
for p in providers {
match p.verify_bearer(token).await {
Ok(Some(principal)) => return Ok(Some(principal)),
Ok(None) => continue,
Err(ProviderError::Invalid) => return Err(ProviderError::Invalid),
Err(ProviderError::Unavailable) => continue,
}
}
Ok(None)
}
/// Dispatch a TRANSPORT-authenticated client-cert CN across an ordered
/// provider set — the mTLS analogue of [`verify_any`]. First `Ok(Some)` wins;
/// `Err(Invalid)` is authoritative; `Ok(None)`/`Err(Unavailable)` fall
/// through. In a mixed set (bearer + oidc + mtls) only the `nornir-auth-mtls`
/// provider claims the CN — the bearer providers default to `Ok(None)` — so a
/// server can hold ONE `Vec<Box<dyn AuthProvider>>` and offer a presented
/// bearer via `verify_any` then the client-cert CN via `verify_any_cert`.
pub async fn verify_any_cert(
providers: &[Box<dyn AuthProvider>],
cn: &str,
) -> Result<Option<Principal>, ProviderError> {
for p in providers {
match p.verify_client_cert(cn).await {
Ok(Some(principal)) => return Ok(Some(principal)),
Ok(None) => continue,
Err(ProviderError::Invalid) => return Err(ProviderError::Invalid),
Err(ProviderError::Unavailable) => continue,
}
}
Ok(None)
}
/// Dispatch a **SAML 2.0 assertion / response** across an ordered provider set
/// — the SAML analogue of [`verify_any`]. First `Ok(Some)` wins; `Err(Invalid)`
/// is authoritative (a signature/condition failure must never be retried into
/// an accept by a laxer provider); `Ok(None)`/`Err(Unavailable)` fall through.
/// In a mixed set only the `nornir-auth-saml` provider claims the assertion —
/// the bearer/cert providers default to `Ok(None)` — so a server can hold ONE
/// `Vec<Box<dyn AuthProvider>>` and offer a POST-bound SAML assertion via
/// `verify_any_saml` beside `verify_any` / `verify_any_cert`.
pub async fn verify_any_saml(
providers: &[Box<dyn AuthProvider>],
assertion_xml: &str,
) -> Result<Option<Principal>, ProviderError> {
for p in providers {
match p.verify_saml_assertion(assertion_xml).await {
Ok(Some(principal)) => return Ok(Some(principal)),
Ok(None) => continue,
Err(ProviderError::Invalid) => return Err(ProviderError::Invalid),
Err(ProviderError::Unavailable) => continue,
}
}
Ok(None)
}
/// Dispatch a **WebAuthn / FIDO2 assertion** across an ordered provider set —
/// the passkey analogue of [`verify_any`]. Carries BOTH serialized halves of
/// the stateful ceremony (`response_json` = the authenticator's signed
/// assertion, `state_json` = the server-held challenge/allow-list + subject).
/// First `Ok(Some)` wins; `Err(Invalid)` is authoritative (a signature /
/// challenge / origin failure must never be retried into an accept by a laxer
/// provider); `Ok(None)`/`Err(Unavailable)` fall through. In a mixed set only
/// the `nornir-auth-webauthn` provider claims the assertion — the
/// bearer/cert/saml providers default to `Ok(None)` — so a server can hold ONE
/// `Vec<Box<dyn AuthProvider>>` and offer a passkey assertion via
/// `verify_any_webauthn` beside `verify_any` / `verify_any_cert` /
/// `verify_any_saml`.
pub async fn verify_any_webauthn(
providers: &[Box<dyn AuthProvider>],
response_json: &str,
state_json: &str,
) -> Result<Option<Principal>, ProviderError> {
for p in providers {
match p.verify_webauthn_assertion(response_json, state_json).await {
Ok(Some(principal)) => return Ok(Some(principal)),
Ok(None) => continue,
Err(ProviderError::Invalid) => return Err(ProviderError::Invalid),
Err(ProviderError::Unavailable) => continue,
}
}
Ok(None)
}
}
/// Constant-time bearer-token comparison. An empty `supplied` token always
/// fails (missing bearer); a non-empty one is compared against `expected` in
/// time that does not depend on the position of the first differing byte.
/// `constant_time_eq` short-circuits on a length mismatch (token length is not
/// secret), then folds an XOR over the bytes.
pub fn token_matches(supplied: &str, expected: &str) -> bool {
if supplied.is_empty() {
return false;
}
constant_time_eq::constant_time_eq(supplied.as_bytes(), expected.as_bytes())
}
/// Extract the `authorization: Bearer <token>` value from a request's gRPC
/// metadata, or `""` when absent/ill-formed.
fn bearer(req: &Request<()>) -> &str {
req.metadata()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.unwrap_or("")
}
/// The bearer-auth tonic interceptor factory. Wrap a service with
/// `Svc::with_interceptor(inner, auth_interceptor(token))`.
///
/// * `token` empty ⇒ **open** (no auth; dev / single-workspace bring-up).
/// * `token` non-empty ⇒ the request MUST carry `authorization: Bearer <token>`
/// whose value [`token_matches`] the configured token (constant-time), else
/// `UNAUTHENTICATED`.
pub fn auth_interceptor(
token: Arc<String>,
) -> impl Fn(Request<()>) -> Result<Request<()>, Status> + Clone {
move |req: Request<()>| -> Result<Request<()>, Status> {
if token.is_empty() {
return Ok(req); // open mode
}
if !token_matches(bearer(&req), token.as_str()) {
return Err(Status::unauthenticated("missing or invalid bearer token"));
}
Ok(req)
}
}
/// The authority a **suite app** defers to when it is *embedded* — compiled INTO a
/// host binary rather than run as its own OS service. In a fat nordisk suite
/// process (e.g. `dwarves-full`, which embeds nornir + skade in one process), the
/// HOST — dwarves — implements this once and hands each embedded app an
/// `Arc<dyn SuiteHost>` at startup. The embedded app authenticates (and, as the
/// suite grows, authorizes) every RPC by calling the host through this trait — an
/// **in-process Rust call, NEVER gRPC / no network hop**. So the HOST's auth rules
/// and the embedded app holds no token of its own (no `~/.nornir/token`).
///
/// Today authentication is a single shared bearer via [`SuiteHost::bearer`]; an
/// empty bearer ⇒ open (dev), exactly as [`auth_interceptor`] treats it. Per-service
/// / per-action authorization (resolving a [`Principal`] through the provider set)
/// hangs off THIS same seam as it lands — the embedded app never grows its own auth
/// authority, it always asks the host.
pub trait SuiteHost: Send + Sync {
/// The host's bearer secret, used to gate the embedded app's RPCs. Empty ⇒
/// open. One secret for the whole fat process — the authentication authority.
fn bearer(&self) -> Arc<String>;
}
/// Build the bearer interceptor for an embedded app straight from its [`SuiteHost`]
/// — the app never resolves a token itself, it asks the host. A thin direct-call
/// wrapper over [`auth_interceptor`] keyed on [`SuiteHost::bearer`].
pub fn host_auth_interceptor(
host: &Arc<dyn SuiteHost>,
) -> impl Fn(Request<()>) -> Result<Request<()>, Status> + Clone {
auth_interceptor(host.bearer())
}
/// The shared `Server::builder() … .serve(addr)` bootstrap. Each server used to
/// hand-roll the identical tail:
///
/// ```ignore
/// Server::builder()
/// .add_service(a)
/// .add_service(b)
/// // …
/// .serve(addr)
/// .await
/// ```
///
/// which differs between the three servers only in *which* services are added.
/// So the caller assembles its (already interceptor-wrapped) services into a
/// [`Routes`] — the dynamic, non-type-changing builder tonic exposes for exactly
/// this — and hands the routes + bind address here. This owns the ONE canonical
/// `tonic::transport::Server` serve loop.
///
/// ```ignore
/// let mut routes = tonic::service::Routes::builder();
/// routes.add_service(HealthServer::new(h));
/// routes.add_service(FooServer::with_interceptor(foo, auth_interceptor(tok.clone())));
/// nornir_service::serve(routes.routes(), addr).await?;
/// ```
///
/// Per-service tuning (a larger `max_encoding_message_size`, an
/// `InterceptedService`, …) is applied to the service *before* it is added to
/// the routes, so nothing app-specific is lost by sharing the tail.
pub async fn serve(routes: Routes, addr: SocketAddr) -> Result<(), tonic::transport::Error> {
let mut server = Server::builder();
server.add_routes(routes).serve(addr).await
}
/// The TLS-aware sibling of [`serve`], gated behind the `tls` cargo feature.
///
/// `holger-server` is the fourth nordisk gRPC server, but unlike
/// nornir/korp/dwarves it terminates TLS (and optional mTLS client-auth) on the
/// tonic `Server` builder itself — the ONE part of its bootstrap tail that the
/// plain [`serve`] could not carry, which is why it kept hand-rolling
/// `Server::builder().tls_config(_)…serve(_)`. This variant folds that tail in:
/// when `tls` is `Some`, it is applied to the builder before the routes; when
/// `None`, it is byte-identical to [`serve`] (plaintext h2c). holger keeps its
/// own richer auth model (OIDC/JWT + mTLS-CN + RBAC) — only the serve loop is
/// shared, not the auth, because holger's auth is a genuinely different model
/// rather than a copied twin of the bearer interceptor.
///
/// Behind a feature so the plaintext consumers (nornir/korp/dwarves) never pull
/// in tonic's TLS stack.
#[cfg(feature = "tls")]
pub async fn serve_with_tls(
routes: Routes,
addr: SocketAddr,
tls: Option<tonic::transport::ServerTlsConfig>,
) -> Result<(), tonic::transport::Error> {
let mut server = Server::builder();
if let Some(cfg) = tls {
server = server.tls_config(cfg)?;
}
server.add_routes(routes).serve(addr).await
}
/// [`serve_with_tls`] with the gRPC-web door open: the same routes, additionally
/// reachable by a browser on the same port.
///
/// Two things differ from [`serve_with_tls`], and they only make sense together:
///
/// * **`GrpcWebLayer`** translates a browser's gRPC-web POST (base64/binary
/// framing over fetch) into native gRPC before it reaches the routes. Native
/// gRPC clients pass through the layer untouched.
/// * **`accept_http1(true)`** — a browser speaking gRPC-web over cleartext uses
/// HTTP/1.1 (browsers never do h2c), so the accept must widen beyond h2. Over
/// TLS, ALPN still prefers h2; native clients are unaffected.
///
/// CORS is deliberately NOT layered here: the expected deployments are
/// same-origin (a dev proxy in front, or the console served by the same host).
/// A cross-origin deployment adds its own CORS layer at the reverse proxy.
#[cfg(feature = "grpc-web")]
pub async fn serve_with_tls_web(
routes: Routes,
addr: SocketAddr,
tls: Option<tonic::transport::ServerTlsConfig>,
) -> Result<(), tonic::transport::Error> {
let mut server = Server::builder().accept_http1(true);
if let Some(cfg) = tls {
server = server.tls_config(cfg)?;
}
server
.layer(tonic_web::GrpcWebLayer::new())
.add_routes(routes)
.serve(addr)
.await
}
/// Resolve the per-request workspace name from a gRPC metadata header, or `None`
/// when the header is absent, non-UTF-8, or empty.
///
/// Both nornir (`nornir-workspace`) and dwarves (`dwarves-workspace`) route a
/// request to one of several served workspaces by a metadata header, each having
/// inlined the same `get → to_str → non-empty` dance. The header *key* stays the
/// caller's (the two apps deliberately use different names), so it is a
/// parameter; the extraction — the copied part — is shared. The caller decides
/// the policy for `None` (nornir errors with the served list; dwarves Phase-0
/// falls back to its single workspace).
pub fn workspace_header<'a>(md: &'a tonic::metadata::MetadataMap, key: &str) -> Option<&'a str> {
md.get(key)
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
}
/// Resolve a server's bearer secret from the operator's environment/filesystem —
/// the ONE resolution ladder the three nordisk servers each hand-rolled
/// (`korp-server`'s `resolve_token_with`, `dwarves-server`'s `resolve_token`,
/// `nornir-server`'s `--token`/env/`<home>/.nornir/token` block). They differed
/// only in two literals, which are the parameters here:
///
/// * `flag` — the `--token <secret>` CLI value the bin parsed (or `None`).
/// * `env_var` — the app's server-token env var (`KORP_SERVER_TOKEN`,
/// `DWARVES_SERVER_TOKEN`, `NORNIR_SERVER_TOKEN`).
/// * `token_file` — the home-derived fallback file (`<home>/.<app>/token`), the
/// SAME 0600 file a same-host client reads so a server + client on one box
/// share one secret with nothing in the unit/env. `None` skips the file step
/// (e.g. `$HOME` unset).
///
/// Resolution order (highest first); each source is used only when it yields a
/// **non-empty** value, so an empty `--token ""` / empty env / whitespace-only
/// file **falls through** to the next step — "unset", never "set to empty":
///
/// 1. `flag` (non-empty) — a passed `--token` always wins.
/// 2. `env_var` (non-empty) — what a rendered systemd unit carries.
/// 3. `token_file` — read + **trimmed**; a missing/unreadable/blank file is
/// simply skipped (open, not an error — bring-up must not require a token).
/// 4. `""` — the empty string ⇒ *open* (the dev / single-workspace default the
/// shared [`auth_interceptor`] treats as no-auth).
///
/// The caller keeps its own **policy** on top of the resolved value: korp/dwarves
/// treat `""` as open (Phase-0); nornir-server instead *requires* a token and
/// rejects one under 16 chars — that guard stays in the conductor, this only
/// resolves the raw secret it guards. This lifts korp-server's testable core
/// verbatim (the reference shape), parameterised on the env var.
///
/// ```ignore
/// // korp-server bin:
/// let tok = nornir_service::resolve_token(cli.token.as_deref(), "KORP_SERVER_TOKEN", korp_token_file().as_deref());
/// let interceptor = nornir_service::auth_interceptor(std::sync::Arc::new(tok));
/// ```
pub fn resolve_token(flag: Option<&str>, env_var: &str, token_file: Option<&Path>) -> String {
resolve_token_whence(flag, env_var, token_file).0
}
/// **WHICH rung of the token ladder answered.**
///
/// [`resolve_token`] returns only the secret, and a caller that has one cannot say
/// where it came from — nor, when it has none, distinguish *nothing is configured*
/// from *the file was there and empty*. Those are different repairs, and a client that
/// reports the wrong one sends its operator to the wrong place.
///
/// MEASURED 2026-08-27: the dwarves UI resolved its bearer with a bare
/// `std::env::var("DWARVES_SERVER_TOKEN").unwrap_or_default()` — no file rung at all —
/// and its own refusal text asserted *"There is no ~/.nornir/token fallback"* as
/// though that were the rule. Both halves need this: the value has to come from the
/// shared ladder, and the message has to name the rung, because on that box the env
/// var and the token file held byte-identical secrets, so inferring the rung by
/// comparing the value to `$VAR` would have answered "env" either way.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenSource {
/// A `--token <secret>` the caller parsed.
Flag,
/// The named environment variable.
Env(String),
/// The token file, at this path.
File(std::path::PathBuf),
/// A cached **OIDC access token** — a real SSO login (`nornir login` /
/// `nornir-auth-client`'s device-code flow), read from its cache with NO network.
///
/// A rung in its own right, not a `Flag`, because the repair is different: a flag is
/// something the operator just typed and can retype, a static file is something to
/// go and write, and an expired SSO session is something to `login` again. Reporting
/// an OIDC token as "an explicitly supplied bearer" sends an operator looking for a
/// `--token` nobody passed. [`resolve_token_whence`] never returns this — it has no
/// login cache to read; a caller that HAS one (nornir's `client_bearer`) inserts this
/// rung between [`Env`](Self::Env) and [`File`](Self::File), which is where it
/// outranks a static token and stays under an explicit one.
Oidc,
/// Nothing yielded a secret. The resolved value is `""` — *open*, for a server;
/// *no credential configured*, for a client.
Nothing,
}
impl TokenSource {
/// A fragment naming the rung, for a message an operator reads.
pub fn describe(&self) -> String {
match self {
TokenSource::Flag => "an explicitly supplied bearer (--token / the host)".to_string(),
TokenSource::Env(v) => format!("${v}"),
TokenSource::File(p) => p.display().to_string(),
TokenSource::Oidc => "a cached OIDC login (`nornir login`)".to_string(),
TokenSource::Nothing => "nothing (no credential configured)".to_string(),
}
}
}
/// [`resolve_token`] plus the rung that answered — the SAME ladder, walked once.
///
/// Both functions exist so no caller has to choose between the shared ladder and
/// knowing where its secret came from; `resolve_token` delegates here, so there is one
/// walk of the rungs and not two that could disagree.
pub fn resolve_token_whence(
flag: Option<&str>,
env_var: &str,
token_file: Option<&Path>,
) -> (String, TokenSource) {
if let Some(t) = flag {
if !t.is_empty() {
return (t.to_string(), TokenSource::Flag);
}
}
if let Ok(env) = std::env::var(env_var) {
if !env.is_empty() {
return (env, TokenSource::Env(env_var.to_string()));
}
}
if let Some(path) = token_file {
if let Ok(contents) = std::fs::read_to_string(path) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return (trimmed.to_string(), TokenSource::File(path.to_path_buf()));
}
}
}
(String::new(), TokenSource::Nothing)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_token_compare_accepts_equal_rejects_any_diff() {
let tok = "s3cret-token-of-sufficient-length";
assert!(token_matches(tok, tok), "identical token must match");
// Empty supplied is always rejected (missing bearer).
assert!(!token_matches("", tok));
// Any single-byte difference rejects.
let mut bytes = tok.as_bytes().to_vec();
bytes[0] ^= 0x01;
assert!(!token_matches(std::str::from_utf8(&bytes).unwrap(), tok));
let mut last = tok.as_bytes().to_vec();
*last.last_mut().unwrap() ^= 0x01;
assert!(!token_matches(std::str::from_utf8(&last).unwrap(), tok));
// Length mismatch (prefix) rejects.
assert!(!token_matches(&tok[..tok.len() - 1], tok));
assert!(!token_matches(tok, &tok[..tok.len() - 1]));
}
fn req_with_auth(value: Option<&str>) -> Request<()> {
let mut req = Request::new(());
if let Some(v) = value {
req.metadata_mut().insert("authorization", v.parse().unwrap());
}
req
}
#[test]
fn interceptor_strict_when_token_set() {
let intercept = auth_interceptor(Arc::new("a-long-enough-secret-token".to_string()));
// Correct bearer passes.
assert!(intercept(req_with_auth(Some("Bearer a-long-enough-secret-token"))).is_ok());
// Wrong / missing / non-bearer rejected.
assert!(intercept(req_with_auth(Some("Bearer nope"))).is_err());
assert!(intercept(req_with_auth(None)).is_err());
assert!(intercept(req_with_auth(Some("Basic a-long-enough-secret-token"))).is_err());
}
#[test]
fn interceptor_open_when_token_empty() {
let intercept = auth_interceptor(Arc::new(String::new()));
assert!(intercept(req_with_auth(None)).is_ok(), "empty token ⇒ open");
assert!(intercept(req_with_auth(Some("Bearer whatever"))).is_ok());
}
#[test]
fn workspace_header_extracts_present_nonempty_only() {
let mut req = Request::new(());
// Absent ⇒ None (caller applies its own no-workspace policy).
assert_eq!(workspace_header(req.metadata(), "nornir-workspace"), None);
// Present + non-empty ⇒ the name (byte-identical to the inlined dance
// nornir-server's `ws()` and dwarves used).
req.metadata_mut().insert("nornir-workspace", "njord".parse().unwrap());
assert_eq!(workspace_header(req.metadata(), "nornir-workspace"), Some("njord"));
// A different key is independent — the two apps use distinct header names.
assert_eq!(workspace_header(req.metadata(), "dwarves-workspace"), None);
// Empty header value ⇒ None (treated as "not selected", not "" workspace).
let mut empty = Request::new(());
empty.metadata_mut().insert("nornir-workspace", "".parse().unwrap());
assert_eq!(workspace_header(empty.metadata(), "nornir-workspace"), None);
}
/// A std-only unique temp path (no `tempfile` dev-dep pulled in for one test):
/// `<tmp>/nornir-service-token-<pid>-<nanos>`. Caller writes/removes it.
fn unique_tmp_token_path() -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("nornir-service-token-{}-{nanos}", std::process::id()))
}
// A test-private env var, so no OTHER crate's tests can race on it. The
// three real apps pass their own literal (`KORP_SERVER_TOKEN` /
// `DWARVES_SERVER_TOKEN` / `NORNIR_SERVER_TOKEN`).
const TEST_ENV: &str = "NORNIR_SERVICE_RESOLVE_TOKEN_TEST";
/// **Both tests below share [`TEST_ENV`], and an environment is process-wide.**
///
/// This comment used to say "no other test touches it, so cargo's parallel
/// test threads cannot race on it". Two tests touch it, and both open with
/// `remove_var` — which is the race, not the cure: the ladder test's
/// `set_var` lands between the other's `remove_var` and its `resolve_token`,
/// the env step then beats the file step, and
/// `resolved_file_token_drives_interceptor_strict` fails claiming the FILE
/// step was dropped. MEASURED roughly 1 run in 6 on unmodified `main`, which
/// is frequent enough to be noticed and rare enough to be blamed on whatever
/// else was in flight.
///
/// A lock is the honest fix rather than `--test-threads=1`: it costs the two
/// tests that genuinely share global state and leaves every other test in
/// this crate running in parallel. Poisoning is ignored on purpose — a panic
/// in one of these must surface as that test's own failure, not as a second,
/// confusing failure in the other.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// The resolution ladder honours `flag > env > file > open`, with every
/// source's **empty** value falling through (never "set to empty"). Table-
/// driven over `(flag, env, file-contents)` → expected. This is the exact
/// precedence korp/dwarves/nornir each hand-rolled; the RED-when-broken guard
/// below neutralises it.
#[test]
fn resolve_token_ladder_flag_over_env_over_file_over_open() {
let _env = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
std::env::remove_var(TEST_ENV);
let path = unique_tmp_token_path();
let _ = std::fs::remove_file(&path);
// Cases with NEITHER env nor file involved (env stays unset here).
// (flag, expected)
let flag_cases: &[(Option<&str>, &str)] = &[
(None, ""), // nothing set ⇒ open
(Some(""), ""), // empty flag falls through, not "set empty"
(Some(" "), " "), // a non-empty flag is used verbatim (korp does not trim the flag)
(Some("flag-secret-of-length"), "flag-secret-of-length"),
];
for (flag, want) in flag_cases {
assert_eq!(
resolve_token(*flag, TEST_ENV, None),
*want,
"flag-only case failed for {flag:?}"
);
}
// Env is used only when no non-empty flag is present; a non-empty flag
// still beats it.
std::env::set_var(TEST_ENV, "env-secret-of-length");
assert_eq!(resolve_token(None, TEST_ENV, None), "env-secret-of-length");
assert_eq!(resolve_token(Some(""), TEST_ENV, None), "env-secret-of-length", "empty flag ⇒ env");
assert_eq!(resolve_token(Some("flag-wins"), TEST_ENV, None), "flag-wins", "flag beats env");
std::env::set_var(TEST_ENV, ""); // empty env falls through
assert_eq!(resolve_token(None, TEST_ENV, None), "", "empty env ⇒ open");
std::env::remove_var(TEST_ENV);
// File is the last-resort secret (below flag+env); read + trimmed.
std::fs::write(&path, "file-token-of-length\n").unwrap();
assert_eq!(resolve_token(None, TEST_ENV, Some(&path)), "file-token-of-length", "file trimmed of newline");
// Whitespace-only file ⇒ still open (trimmed to empty).
std::fs::write(&path, " \n\t").unwrap();
assert_eq!(resolve_token(None, TEST_ENV, Some(&path)), "", "blank file ⇒ open");
// A missing file is not an error ⇒ open.
std::fs::remove_file(&path).unwrap();
assert_eq!(resolve_token(None, TEST_ENV, Some(&path)), "", "missing file ⇒ open");
// Env beats the file; flag beats both.
std::fs::write(&path, "file-token-of-length\n").unwrap();
std::env::set_var(TEST_ENV, "env-beats-file");
assert_eq!(resolve_token(None, TEST_ENV, Some(&path)), "env-beats-file", "env beats file");
assert_eq!(resolve_token(Some("flag-beats-all"), TEST_ENV, Some(&path)), "flag-beats-all", "flag beats env+file");
// ── AND THE LADDER SAYS WHICH RUNG ANSWERED ──────────────────────────────
//
// This is what a CLIENT needs and `resolve_token` cannot give it: the same
// secret means different things depending on where it came from, and "no
// credential configured" is a different repair from "token rejected".
//
// The env var and the file deliberately hold the SAME bytes here, because that
// is the state on oden (~/.dwarves/token and $DWARVES_SERVER_TOKEN are
// byte-identical) and it is exactly the state in which inferring the rung by
// comparing the resolved value to $VAR answers "env" for both — the shortcut
// this function exists instead of.
std::fs::write(&path, "same-secret-both-places\n").unwrap();
std::env::set_var(TEST_ENV, "same-secret-both-places");
let (v, whence) = resolve_token_whence(None, TEST_ENV, Some(&path));
assert_eq!(v, "same-secret-both-places");
assert_eq!(whence, TokenSource::Env(TEST_ENV.to_string()), "env is the higher rung");
assert_eq!(whence.describe(), format!("${TEST_ENV}"));
assert!(TokenSource::Flag.describe().contains("explicitly supplied"), "{}", TokenSource::Flag.describe());
std::env::remove_var(TEST_ENV);
let (v, whence) = resolve_token_whence(None, TEST_ENV, Some(&path));
assert_eq!(v, "same-secret-both-places", "the SAME value…");
assert_eq!(whence, TokenSource::File(path.clone()), "…and now the FILE is what answered");
assert!(whence.describe().contains("token"), "{}", whence.describe());
// A flag wins and says so.
let (v, whence) = resolve_token_whence(Some("flag-secret"), TEST_ENV, Some(&path));
assert_eq!((v.as_str(), whence), ("flag-secret", TokenSource::Flag));
// Nothing anywhere: `Nothing`, not a rung that happened to be empty. The
// resolved value is "" — open for a server, unconfigured for a client, and the
// caller keeps that policy.
std::fs::remove_file(&path).unwrap();
let (v, whence) = resolve_token_whence(None, TEST_ENV, Some(&path));
assert_eq!(v, "");
assert_eq!(whence, TokenSource::Nothing);
assert!(whence.describe().contains("no credential configured"), "{}", whence.describe());
// A blank file is Nothing too — "set to empty" is not a credential.
std::fs::write(&path, " \n").unwrap();
assert_eq!(resolve_token_whence(None, TEST_ENV, Some(&path)).1, TokenSource::Nothing);
// The two entry points cannot drift: `resolve_token` IS this ladder's value.
std::fs::write(&path, "one-writer\n").unwrap();
assert_eq!(
resolve_token(None, TEST_ENV, Some(&path)),
resolve_token_whence(None, TEST_ENV, Some(&path)).0,
"resolve_token must delegate, not re-walk the rungs"
);
std::env::remove_var(TEST_ENV);
std::fs::remove_file(&path).unwrap();
}
/// RED-when-broken pin: the resolved token drives the SHARED interceptor
/// end-to-end. A file-resolved secret must reject a wrong/missing bearer and
/// accept the right one; an empty resolution ⇒ open. If the file step of the
/// ladder were dropped (regression), the secret would resolve to `""`, the
/// interceptor would go OPEN, and the "wrong bearer rejected" assertion below
/// would fail — this is the ≥1 red-when-broken assertion for the ladder.
#[test]
fn resolved_file_token_drives_interceptor_strict() {
let _env = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
std::env::remove_var(TEST_ENV);
let path = unique_tmp_token_path();
std::fs::write(&path, "a-file-resolved-secret\n").unwrap();
let tok = resolve_token(None, TEST_ENV, Some(&path));
assert_eq!(tok, "a-file-resolved-secret", "file token must resolve (RED if file step dropped)");
let strict = auth_interceptor(Arc::new(tok));
let mut ok = Request::new(());
ok.metadata_mut().insert("authorization", "Bearer a-file-resolved-secret".parse().unwrap());
assert!(strict(ok).is_ok(), "correct bearer passes");
let mut bad = Request::new(());
bad.metadata_mut().insert("authorization", "Bearer nope".parse().unwrap());
assert!(strict(bad).is_err(), "wrong bearer rejected (RED if ladder went open)");
assert!(strict(Request::new(())).is_err(), "missing bearer rejected");
// An all-blank resolution ⇒ open (empty token, no bearer needed).
std::fs::write(&path, " \n").unwrap();
let open = auth_interceptor(Arc::new(resolve_token(None, TEST_ENV, Some(&path))));
assert!(open(Request::new(())).is_ok(), "blank ⇒ open, no bearer needed");
std::fs::remove_file(&path).unwrap();
}
// `serve` is exercised end-to-end by nornir-server / dwarves-server against a
// real bind; here we only pin its signature so the shared bootstrap tail
// cannot drift from `Server::builder().add_routes(_).serve(_)` under a tonic
// bump without this leaf failing to compile.
#[allow(dead_code)]
fn serve_signature_is_stable() {
let _f: fn(
Routes,
std::net::SocketAddr,
)
-> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), tonic::transport::Error>>>> =
|routes, addr| Box::pin(serve(routes, addr));
}
// Same signature pin for the TLS-aware sibling holger-server consumes, so the
// shared serve loop cannot drift from `Server::builder().tls_config(_)?
// .add_routes(_).serve(_)` under a tonic bump without this leaf failing.
#[cfg(feature = "tls")]
#[allow(dead_code)]
fn serve_with_tls_signature_is_stable() {
let _f: fn(
Routes,
std::net::SocketAddr,
Option<tonic::transport::ServerTlsConfig>,
)
-> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), tonic::transport::Error>>>> =
|routes, addr, tls| Box::pin(serve_with_tls(routes, addr, tls));
}
}
/// The pluggable-provider seam: `Principal`, dispatch order, and object-safety.
#[cfg(all(test, feature = "auth-provider"))]
mod provider_tests {
use super::provider::*;
use tonic::async_trait;
/// A canned provider: recognizes exactly `owns` (→ its subject) and answers
/// everything else per `on_other` (None = "not mine", or a hard Invalid).
struct Canned {
name: &'static str,
owns: &'static str,
subject: &'static str,
hard_reject: bool,
}
#[async_trait]
impl AuthProvider for Canned {
fn name(&self) -> &str {
self.name
}
async fn verify_bearer(&self, token: &str) -> Result<Option<Principal>, ProviderError> {
if token == self.owns {
Ok(Some(Principal::new(self.subject, self.name).with_scopes(vec!["write".into()])))
} else if self.hard_reject {
Err(ProviderError::Invalid)
} else {
Ok(None)
}
}
}
/// The trait is object-safe: it can be held as `Box<dyn AuthProvider>` (the
/// seam a future wasmtime provider slots into). If it weren't, this wouldn't
/// compile.
#[test]
fn trait_is_object_safe() {
let _p: Box<dyn AuthProvider> =
Box::new(Canned { name: "bearer", owns: "k", subject: "s", hard_reject: false });
}
/// First `Ok(Some)` wins; a provider that says "not mine" is skipped.
#[tokio::test]
async fn dispatch_first_match_wins_skips_not_mine() {
let providers: Vec<Box<dyn AuthProvider>> = vec![
Box::new(Canned { name: "oidc", owns: "jwt-tok", subject: "alice", hard_reject: false }),
Box::new(Canned { name: "bearer", owns: "api-key", subject: "ci-bot", hard_reject: false }),
];
let p = verify_any(&providers, "api-key").await.unwrap().expect("authenticated");
assert_eq!(p.subject, "ci-bot");
assert_eq!(p.method, "bearer");
assert!(p.has_scope("write"));
// A credential no provider recognizes ⇒ Ok(None) (caller decides).
assert!(verify_any(&providers, "unknown").await.unwrap().is_none());
}
/// A provider's hard `Invalid` is authoritative — the dispatcher does NOT let
/// a later, laxer provider turn a rejected credential into an accept.
#[tokio::test]
async fn hard_invalid_is_authoritative() {
let providers: Vec<Box<dyn AuthProvider>> = vec![
Box::new(Canned { name: "oidc", owns: "good", subject: "alice", hard_reject: true }),
Box::new(Canned { name: "bearer", owns: "bad", subject: "evil", hard_reject: false }),
];
// "bad" is rejected hard by oidc (its shape) → Invalid, never reaches the
// bearer provider that would have accepted "bad".
assert_eq!(verify_any(&providers, "bad").await.unwrap_err(), ProviderError::Invalid);
}
/// A cert provider recognizes a client-cert CN; bearer providers default their
/// `verify_client_cert` to "not mine". In ONE mixed set, `verify_any_cert`
/// routes the CN to the cert provider and `verify_any` routes a bearer to the
/// bearer provider — the two passes never cross-claim. This is exactly how the
/// mTLS provider (`nornir-auth-mtls`) coexists with the bearer providers.
#[tokio::test]
async fn verify_any_cert_routes_cn_to_cert_provider() {
/// A minimal cert provider: any CN → identity; bearers are "not mine".
struct CertOnly;
#[async_trait]
impl AuthProvider for CertOnly {
fn name(&self) -> &str {
"mtls"
}
async fn verify_bearer(&self, _t: &str) -> Result<Option<Principal>, ProviderError> {
Ok(None)
}
async fn verify_client_cert(
&self,
cn: &str,
) -> Result<Option<Principal>, ProviderError> {
Ok(Some(Principal::new(cn, "mtls")))
}
}
let providers: Vec<Box<dyn AuthProvider>> = vec![
Box::new(Canned { name: "bearer", owns: "api-key", subject: "ci-bot", hard_reject: false }),
Box::new(CertOnly),
];
// The CN is claimed by the cert provider (the bearer provider defaults its
// `verify_client_cert` to Ok(None)).
let cert = verify_any_cert(&providers, "CN=agent-07").await.unwrap().expect("cn identity");
assert_eq!(cert.subject, "CN=agent-07");
assert_eq!(cert.method, "mtls");
// A bearer still routes to the bearer provider on the other pass; the cert
// provider ignores it.
let bearer = verify_any(&providers, "api-key").await.unwrap().expect("bearer");
assert_eq!(bearer.method, "bearer");
}
/// A SAML provider claims a SAML assertion via `verify_saml_assertion`; the
/// bearer/cert providers default it to "not mine". `verify_any_saml` routes the
/// assertion to the SAML provider without any other provider cross-claiming it,
/// and an authoritative `Invalid` (bad signature / expired) stops the dispatch.
/// This is exactly how `nornir-auth-saml` coexists with the other providers.
#[tokio::test]
async fn verify_any_saml_routes_assertion_to_saml_provider() {
/// A minimal SAML provider: an assertion containing `good` → identity;
/// one containing `bad` → authoritative Invalid; bearers are "not mine".
struct SamlOnly;
#[async_trait]
impl AuthProvider for SamlOnly {
fn name(&self) -> &str {
"saml"
}
async fn verify_bearer(&self, _t: &str) -> Result<Option<Principal>, ProviderError> {
Ok(None)
}
async fn verify_saml_assertion(
&self,
xml: &str,
) -> Result<Option<Principal>, ProviderError> {
if xml.contains("bad") {
return Err(ProviderError::Invalid);
}
Ok(Some(Principal::new("saml-user", "saml")))
}
}
let providers: Vec<Box<dyn AuthProvider>> = vec![
Box::new(Canned { name: "bearer", owns: "api-key", subject: "ci-bot", hard_reject: false }),
Box::new(SamlOnly),
];
// A valid assertion is claimed by the SAML provider; the bearer provider
// defaults its `verify_saml_assertion` to Ok(None).
let ok = verify_any_saml(&providers, "<Assertion>good</Assertion>")
.await
.unwrap()
.expect("saml identity");
assert_eq!(ok.subject, "saml-user");
assert_eq!(ok.method, "saml");
// A failed assertion is an authoritative reject (never falls through to an
// accept). RED-when-broken: a laxer fall-through would re-open a bad SAML.
assert_eq!(
verify_any_saml(&providers, "<Assertion>bad</Assertion>").await,
Err(ProviderError::Invalid)
);
}
/// A WebAuthn provider claims a passkey assertion via `verify_webauthn_assertion`
/// (taking BOTH the signed response AND the server-held state); the
/// bearer/cert/saml providers default it to "not mine". `verify_any_webauthn`
/// routes the assertion to the WebAuthn provider without any other provider
/// cross-claiming it, and an authoritative `Invalid` (bad signature / wrong
/// challenge / wrong origin) stops the dispatch. This is exactly how
/// `nornir-auth-webauthn` coexists with the other providers.
#[tokio::test]
async fn verify_any_webauthn_routes_assertion_to_webauthn_provider() {
/// A minimal WebAuthn provider: a response containing `good` verified
/// against a non-empty state → identity; one containing `bad` →
/// authoritative Invalid; bearers/certs/saml are "not mine".
struct WebauthnOnly;
#[async_trait]
impl AuthProvider for WebauthnOnly {
fn name(&self) -> &str {
"webauthn"
}
async fn verify_bearer(&self, _t: &str) -> Result<Option<Principal>, ProviderError> {
Ok(None)
}
async fn verify_webauthn_assertion(
&self,
response_json: &str,
state_json: &str,
) -> Result<Option<Principal>, ProviderError> {
if state_json.trim().is_empty() {
return Ok(None);
}
if response_json.contains("bad") {
return Err(ProviderError::Invalid);
}
Ok(Some(Principal::new("passkey-user", "webauthn")))
}
}
let providers: Vec<Box<dyn AuthProvider>> = vec![
Box::new(Canned { name: "bearer", owns: "api-key", subject: "ci-bot", hard_reject: false }),
Box::new(WebauthnOnly),
];
// A valid assertion is claimed by the WebAuthn provider; the bearer provider
// defaults its `verify_webauthn_assertion` to Ok(None).
let ok = verify_any_webauthn(&providers, "{\"good\":true}", "{\"challenge\":\"x\"}")
.await
.unwrap()
.expect("webauthn identity");
assert_eq!(ok.subject, "passkey-user");
assert_eq!(ok.method, "webauthn");
// A failed assertion is an authoritative reject (never falls through to an
// accept). RED-when-broken: a laxer fall-through would re-open a bad passkey.
assert_eq!(
verify_any_webauthn(&providers, "{\"bad\":true}", "{\"challenge\":\"x\"}").await,
Err(ProviderError::Invalid)
);
}
}
/// **WebAuthn relying-party validation** — pure string work, no crypto, no deps.
///
/// It lives in the SEAM crate rather than in the passkey provider because two
/// different callers need the same answer and one of them cannot reach the
/// provider: a server's BOOT GATE has to ask "can this relying party work in a
/// browser at all" in a build that pinned no crypto backend. Reaching into an
/// optional dependency to ask is how a check ends up existing in two versions
/// that quietly disagree, and this particular check is the phishing boundary.
pub mod passkey_rp {
/// Is this a usable WebAuthn relying-party id?
///
/// Exposed as a free function, not just as a constructor check, because a server's
/// **boot gate** has to be able to ask the question without building a relying
/// party — and because that gate must exist in builds that did not pin the crypto
/// backend at all. Pure string work: no crypto, no allocation beyond the message.
///
/// # Errors
///
/// An empty id, or an IP literal (v4 or v6). Browsers reject an address as an RP
/// ID outright, so such a deployment offers a login door that opens onto nothing.
pub fn validate_rp_id(rp_id: &str) -> Result<(), String> {
let rp_id = rp_id.trim();
if rp_id.is_empty() {
return Err("the relying-party id is empty; set it to the DNS name browsers \
reach this server by"
.to_owned());
}
// An IPv6 literal may be written bare here (it is a host, not a URL authority),
// so strip any brackets before asking.
let bare = rp_id.trim_start_matches('[').trim_end_matches(']');
if bare.parse::<std::net::IpAddr>().is_ok() {
return Err(format!(
"the relying-party id is {rp_id:?}, which is an IP address. WebAuthn requires a \
DOMAIN NAME — a browser will refuse to register or use a passkey against an \
address, so no key could ever be enrolled. Give this server a NAME that resolves \
inside the deployment (a DNS record, an /etc/hosts entry on the clients, or the \
appliance's own resolver), browse to that name, and set the relying-party id to it."
));
}
if rp_id.contains('/') || rp_id.contains(':') {
return Err(format!(
"the relying-party id is {rp_id:?}; it must be a bare domain name — no scheme, no \
port, no path. The full origin is configured separately."
));
}
Ok(())
}
/// Does `origin` belong to `rp_id`?
///
/// WebAuthn's rule: the origin's host must equal the `rp_id` or be a subdomain of
/// it. `https://holger.lab.internal` is valid for `rp_id = lab.internal` and for
/// `rp_id = holger.lab.internal`; it is not valid for `rp_id = example.com`.
///
/// `http://localhost…` is permitted because browsers treat localhost as a secure
/// context; every other origin must be `https`, since a browser will not run a
/// WebAuthn ceremony on an insecure one and the resulting failure names neither
/// this server nor the cause.
///
/// # Errors
///
/// A malformed origin, an insecure scheme, or a host outside the `rp_id`.
pub fn validate_origin_for_rp_id(origin: &str, rp_id: &str) -> Result<(), String> {
let origin = origin.trim().trim_end_matches('/');
if origin.is_empty() {
return Err("the relying-party origin is empty; set it to the exact origin the \
browser shows, scheme and port included"
.to_owned());
}
let (scheme, rest) = origin
.split_once("://")
.ok_or_else(|| format!("the relying-party origin {origin:?} has no scheme"))?;
// Strip any port; an IPv6 host is bracketed, so find the port after the bracket.
let host_part = rest.split('/').next().unwrap_or(rest);
let host = if let Some(close) = host_part.rfind(']') {
&host_part[..=close]
} else {
host_part.split(':').next().unwrap_or(host_part)
};
let host = host.trim_start_matches('[').trim_end_matches(']');
let is_localhost = host == "localhost" || host == "127.0.0.1" || host == "::1";
if scheme != "https" && !(scheme == "http" && is_localhost) {
return Err(format!(
"the relying-party origin {origin:?} is not a secure context. A browser runs a \
WebAuthn ceremony only over https (or http on localhost, for development), and \
refuses with a message that names neither this server nor the reason."
));
}
if is_localhost && rp_id == "localhost" {
return Ok(());
}
if host == rp_id || host.ends_with(&format!(".{rp_id}")) {
return Ok(());
}
Err(format!(
"the relying-party origin {origin:?} has host {host:?}, which is neither the \
relying-party id {rp_id:?} nor a subdomain of it. The origin is what an assertion is \
checked against and the id is what the credential is scoped to: if those two may \
disagree, the check that binds a credential to THIS server is checking something else."
))
}
}