crates/nornir-auth-bearer/src/lib.rs
//! # nornir-auth-bearer
//!
//! The **static Bearer token / API-key** auth provider — the small, MODERN
//! machine-to-machine path for CI runners, agents, and headless clients that
//! cannot run an interactive OIDC device/auth-code flow. It is the leanest
//! provider (no crypto, no HTTP): the presented `authorization: Bearer <token>`
//! is compared **constant-time** against a secret, and on a match the request
//! authenticates as a configured `subject` (which a server's RBAC layer then maps
//! to a role, exactly like an OIDC `sub`).
//!
//! This crate is the deliberate REUSE point (Law #1 / #5): it does **not**
//! re-implement the bearer-secret resolution ladder or the constant-time compare
//! — it consumes [`nornir_service::resolve_token`] (inline > env var > file, the
//! same ladder every nordisk server uses, extensible to the future dwarves vault)
//! and [`nornir_service::token_matches`]. It implements the ONE shared
//! [`nornir_service::AuthProvider`] seam so a server dispatches a credential to it
//! alongside the OIDC / OAuth2 providers, and — being a feature-gated sub-crate —
//! the embedded/mini tier can ship THIS provider alone, linking none of the
//! heavier providers' dependency closures ("ship small").
use std::path::Path;
use nornir_service::provider::{AuthProvider, Principal, ProviderError};
use tonic::async_trait;
/// A static Bearer token / API-key provider. The secret is resolved ONCE (at
/// construction, through the shared ladder) and held; each verify is a
/// constant-time compare. A secret that resolves EMPTY leaves the provider
/// **disabled** (`is_enabled() == false`) — it authenticates no credential
/// (fail-closed: a mis-provisioned/unset token never drops the gate open).
#[derive(Debug, Clone)]
pub struct BearerProvider {
subject: String,
secret: String,
scopes: Vec<String>,
}
impl BearerProvider {
/// Build from an already-resolved secret. `scopes` are the coarse grants the
/// key carries (e.g. `["write"]`), surfaced on the [`Principal`].
pub fn new(
subject: impl Into<String>,
secret: impl Into<String>,
scopes: Vec<String>,
) -> Self {
Self { subject: subject.into(), secret: secret.into(), scopes }
}
/// Build by RESOLVING the secret through the shared nordisk ladder
/// ([`nornir_service::resolve_token`]): inline `token` value > `env_var`
/// environment variable > `token_file` on disk > `""`. This is the seam that
/// makes the secret sourceable from a rendered systemd unit, a 0600 file, or a
/// vault-injected env WITHOUT this crate knowing where it came from.
pub fn resolved(
subject: impl Into<String>,
inline: Option<&str>,
env_var: &str,
token_file: Option<&Path>,
scopes: Vec<String>,
) -> Self {
let secret = nornir_service::resolve_token(inline, env_var, token_file);
Self { subject: subject.into(), secret, scopes }
}
/// A configured secret resolved to something non-empty. A disabled provider
/// authenticates nothing (used by a server to warn / to skip wiring it).
pub fn is_enabled(&self) -> bool {
!self.secret.is_empty()
}
/// The principal name this provider authenticates as.
pub fn subject(&self) -> &str {
&self.subject
}
}
#[async_trait]
impl AuthProvider for BearerProvider {
fn name(&self) -> &str {
"bearer"
}
async fn verify_bearer(&self, token: &str) -> Result<Option<Principal>, ProviderError> {
// Disabled (empty secret) ⇒ authenticate nothing, fail-closed.
if self.secret.is_empty() {
return Ok(None);
}
if nornir_service::token_matches(token, &self.secret) {
return Ok(Some(
Principal::new(self.subject.clone(), "bearer").with_scopes(self.scopes.clone()),
));
}
// A non-matching token is "not mine" — it may be an OIDC JWT or a
// different API key another provider owns. Never a hard `Invalid`: the
// dispatcher must be free to try the next provider.
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scopes(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
/// The matching key authenticates as the configured subject with its scopes;
/// a wrong key is "not mine" (Ok(None)); an empty bearer never authenticates.
#[tokio::test]
async fn matching_key_authenticates_wrong_is_not_mine() {
let p = BearerProvider::new("ci-runner", "api-key-of-sufficient-length", scopes(&["write"]));
assert!(p.is_enabled());
let ok = p.verify_bearer("api-key-of-sufficient-length").await.unwrap().expect("authenticated");
assert_eq!(ok.subject, "ci-runner");
assert_eq!(ok.method, "bearer");
assert!(ok.has_scope("write"));
// Wrong token ⇒ Ok(None) ("not mine"), NOT an authenticated principal.
assert!(p.verify_bearer("wrong-key").await.unwrap().is_none(), "wrong key must not authenticate");
// Empty bearer ⇒ never authenticates (constant-time compare rejects it).
assert!(p.verify_bearer("").await.unwrap().is_none(), "empty bearer must not authenticate");
}
/// A provider whose secret resolves EMPTY is disabled and authenticates
/// nothing — even a caller who somehow sent an empty bearer. RED-when-broken:
/// a bug letting an empty secret match would authenticate an arbitrary token.
#[tokio::test]
async fn empty_secret_provider_is_disabled_fail_closed() {
let p = BearerProvider::new("ghost", "", scopes(&[]));
assert!(!p.is_enabled());
assert!(p.verify_bearer("anything").await.unwrap().is_none());
assert!(p.verify_bearer("").await.unwrap().is_none());
}
/// The secret is RESOLVED through the shared ladder — env var and file
/// sources authenticate identically to an inline secret (the REUSE proof:
/// this crate does not twin the resolution ladder).
#[tokio::test]
async fn resolves_secret_via_shared_ladder_env_and_file() {
// Env-var source.
let env_var = "HOLGER_AUTH_BEARER_TEST_TOKEN";
std::env::set_var(env_var, "env-provisioned-secret");
let p = BearerProvider::resolved("env-bot", None, env_var, None, scopes(&["write"]));
assert!(p.is_enabled());
assert_eq!(
p.verify_bearer("env-provisioned-secret").await.unwrap().unwrap().subject,
"env-bot"
);
std::env::remove_var(env_var);
// File source (trimmed by the shared ladder).
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("api-token");
std::fs::write(&path, "file-provisioned-secret\n").unwrap();
let pf = BearerProvider::resolved("file-bot", None, "UNSET_ENV_VAR_X", Some(&path), scopes(&[]));
assert_eq!(
pf.verify_bearer("file-provisioned-secret").await.unwrap().unwrap().subject,
"file-bot"
);
// Inline wins over env + file (ladder precedence).
std::env::set_var(env_var, "env-loser");
let pi = BearerProvider::resolved("inline-bot", Some("inline-winner"), env_var, Some(&path), scopes(&[]));
assert_eq!(pi.verify_bearer("inline-winner").await.unwrap().unwrap().subject, "inline-bot");
assert!(pi.verify_bearer("env-loser").await.unwrap().is_none());
std::env::remove_var(env_var);
}
}