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

nordisk/edda

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);
    }
}