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-apikey/src/lib.rs

//! # nornir-auth-apikey
//!
//! The **hashed static API-key** auth provider — the sibling of
//! [`nornir-auth-bearer`] for the case where an operator holds **many** keys and
//! must not persist any of them in the clear. Where `nornir-auth-bearer` compares
//! a presented token against ONE plaintext secret, this provider compares it
//! against a **store of SHA-256 digests** (hashed-at-rest): the plaintext key is
//! shown to the caller once at issuance and never stored, so a leaked config /
//! backup does not leak usable credentials. It suits rotating / multi-tenant key
//! stores.
//!
//! It REUSES the shared primitives (Law #1/#5) — [`nornir_hash::sha256_hex`] for
//! the digest and [`nornir_service::token_matches`] for the constant-time compare
//! — so it carries no crypto/HTTP of its own and stays a tiny provider every tier
//! can pin. It implements the ONE shared [`nornir_service::AuthProvider`] seam via
//! [`verify_bearer`](nornir_service::provider::AuthProvider::verify_bearer),
//! authenticating a matched key as `method = "apikey"`.
//!
//! ```
//! use nornir_auth_apikey::{ApiKeyProvider, ApiKey};
//! use nornir_service::provider::AuthProvider;
//! # async fn demo() {
//! // Store the HASH, never the plaintext. `hash_key` produces the stored digest.
//! let stored = ApiKeyProvider::hash_key("sk-live-abcdef0123456789");
//! let p = ApiKeyProvider::new(vec![ApiKey::new("tenant-a", stored, vec!["read".into()])]);
//! let id = p.verify_bearer("sk-live-abcdef0123456789").await.unwrap().unwrap();
//! assert_eq!(id.subject, "tenant-a");
//! assert_eq!(id.method, "apikey");
//! # }
//! ```

use nornir_service::provider::{AuthProvider, Principal, ProviderError};
use tonic::async_trait;

/// One stored API key: the SHA-256 **hash** of the key (lowercase hex, never the
/// plaintext), the `subject` it authenticates as, and the `scopes` it grants.
#[derive(Debug, Clone)]
pub struct ApiKey {
    /// Lowercase-hex SHA-256 of the plaintext key ([`ApiKeyProvider::hash_key`]).
    pub key_hash: String,
    /// The identity this key authenticates as.
    pub subject: String,
    /// Coarse grants carried by the key.
    pub scopes: Vec<String>,
}

impl ApiKey {
    /// Build a stored key entry from an already-hashed digest.
    pub fn new(
        subject: impl Into<String>,
        key_hash: impl Into<String>,
        scopes: Vec<String>,
    ) -> Self {
        Self { key_hash: key_hash.into(), subject: subject.into(), scopes }
    }
}

/// A hashed static API-key provider. Holds a set of [`ApiKey`] entries; a presented
/// token is hashed and compared (constant-time) against each stored digest.
#[derive(Debug, Clone, Default)]
pub struct ApiKeyProvider {
    keys: Vec<ApiKey>,
}

impl ApiKeyProvider {
    /// Build from a set of stored (hashed) keys.
    pub fn new(keys: Vec<ApiKey>) -> Self {
        Self { keys }
    }

    /// Hash a plaintext key into the digest to STORE (lowercase-hex SHA-256).
    /// Reuses the shared [`nornir_hash::sha256_hex`] primitive. Use this at key
    /// issuance / config generation — the plaintext is never persisted.
    pub fn hash_key(plaintext: &str) -> String {
        nornir_hash::sha256_hex(plaintext.as_bytes())
    }

    /// A provider with at least one stored key. An empty store authenticates
    /// nothing (fail-closed).
    pub fn is_enabled(&self) -> bool {
        !self.keys.is_empty()
    }
}

#[async_trait]
impl AuthProvider for ApiKeyProvider {
    fn name(&self) -> &str {
        "apikey"
    }

    /// Hash the presented token and compare it, constant-time, against every stored
    /// digest. The first match authenticates as that key's subject with its scopes.
    /// A non-matching / empty token is `Ok(None)` ("not mine" — it may be an OIDC
    /// JWT or a different provider's credential; never a hard `Invalid`, so the
    /// dispatcher stays free to try the next provider).
    async fn verify_bearer(&self, token: &str) -> Result<Option<Principal>, ProviderError> {
        if token.is_empty() || self.keys.is_empty() {
            return Ok(None);
        }
        let presented = nornir_hash::sha256_hex(token.as_bytes());
        for key in &self.keys {
            // Constant-time compare of the fixed-length hex digests (reused shared
            // primitive) — no early-exit timing leak across the key set.
            if nornir_service::token_matches(&presented, &key.key_hash) {
                return Ok(Some(
                    Principal::new(key.subject.clone(), "apikey").with_scopes(key.scopes.clone()),
                ));
            }
        }
        Ok(None)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn scopes(v: &[&str]) -> Vec<String> {
        v.iter().map(|s| s.to_string()).collect()
    }

    /// The right key (hashed match) authenticates as its subject with scopes; a
    /// wrong key is "not mine"; an empty token never authenticates. RED-when-broken:
    /// this is the whole verify contract.
    #[tokio::test]
    async fn matching_hashed_key_authenticates_wrong_is_not_mine() {
        let hash = ApiKeyProvider::hash_key("sk-live-secret-value-01");
        let p = ApiKeyProvider::new(vec![
            ApiKey::new("tenant-a", hash, scopes(&["read", "write"])),
        ]);
        assert!(p.is_enabled());

        let ok = p.verify_bearer("sk-live-secret-value-01").await.unwrap().expect("authenticated");
        assert_eq!(ok.subject, "tenant-a");
        assert_eq!(ok.method, "apikey");
        assert!(ok.has_scope("write"));

        assert!(p.verify_bearer("sk-live-WRONG").await.unwrap().is_none(), "wrong key not mine");
        assert!(p.verify_bearer("").await.unwrap().is_none(), "empty token never authenticates");
    }

    /// The store holds only the HASH — the plaintext never appears in it (the
    /// hashed-at-rest property that distinguishes this from the plaintext bearer).
    #[test]
    fn store_holds_hash_not_plaintext() {
        let plaintext = "sk-live-topsecret";
        let hash = ApiKeyProvider::hash_key(plaintext);
        assert_ne!(hash, plaintext);
        assert_eq!(hash.len(), 64, "lowercase-hex SHA-256 is 64 chars");
        assert!(!hash.contains(plaintext));
    }

    /// Multiple keys resolve to their own subjects; an empty store is disabled and
    /// authenticates nothing (fail-closed).
    #[tokio::test]
    async fn multi_key_store_and_empty_store_fail_closed() {
        let p = ApiKeyProvider::new(vec![
            ApiKey::new("svc-1", ApiKeyProvider::hash_key("key-one"), scopes(&[])),
            ApiKey::new("svc-2", ApiKeyProvider::hash_key("key-two"), scopes(&["admin"])),
        ]);
        assert_eq!(p.verify_bearer("key-one").await.unwrap().unwrap().subject, "svc-1");
        let two = p.verify_bearer("key-two").await.unwrap().unwrap();
        assert_eq!(two.subject, "svc-2");
        assert!(two.has_scope("admin"));

        let empty = ApiKeyProvider::default();
        assert!(!empty.is_enabled());
        assert!(empty.verify_bearer("key-one").await.unwrap().is_none());
    }
}