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