use std::collections::HashMap;
use std::sync::RwLock;
use std::time::SystemTime;
use axum::{
extract::{Request, State},
http::{header::AUTHORIZATION, StatusCode},
middleware::Next,
response::Response,
};
use super::capability::CapabilitySet;
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub enabled: bool,
pub header_name: String,
pub prefix: String,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enabled: true,
header_name: AUTHORIZATION.to_string(),
prefix: "Bearer ".to_string(),
}
}
}
impl AuthConfig {
pub fn disabled() -> Self {
Self {
enabled: false,
..Default::default()
}
}
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
..Default::default()
}
}
}
#[derive(Debug, Clone)]
pub struct TokenRecord {
pub id: String,
pub capabilities: CapabilitySet,
pub label: String,
pub created_at: SystemTime,
}
impl TokenRecord {
pub fn new(capabilities: CapabilitySet, label: impl Into<String>) -> Self {
Self {
id: generate_token_id(),
capabilities,
label: label.into(),
created_at: SystemTime::now(),
}
}
pub fn full_control(label: impl Into<String>) -> Self {
Self::new(CapabilitySet::wildcard(), label)
}
}
#[derive(Debug)]
pub struct ApiKeyStore {
tokens: RwLock<HashMap<String, TokenRecord>>,
config: AuthConfig,
}
impl ApiKeyStore {
pub fn new(config: AuthConfig) -> Self {
Self {
tokens: RwLock::new(HashMap::new()),
config,
}
}
pub fn disabled() -> Self {
Self::new(AuthConfig::disabled())
}
pub fn add_key(&self, key: impl Into<String>) {
self.add_token(key, TokenRecord::full_control("legacy"));
}
pub fn add_token(&self, key: impl Into<String>, record: TokenRecord) {
if let Ok(mut tokens) = self.tokens.write() {
tokens.insert(key.into(), record);
}
}
pub fn add_key_with_capabilities(
&self,
key: impl Into<String>,
capabilities: CapabilitySet,
label: impl Into<String>,
) {
self.add_token(key, TokenRecord::new(capabilities, label));
}
pub fn remove_key(&self, key: &str) -> bool {
self.tokens
.write()
.map(|mut tokens| tokens.remove(key).is_some())
.unwrap_or(false)
}
pub fn is_valid(&self, key: &str) -> bool {
self.tokens
.read()
.map(|tokens| tokens.contains_key(key))
.unwrap_or(false)
}
pub fn capabilities(&self, key: &str) -> Option<CapabilitySet> {
self.tokens
.read()
.ok()
.and_then(|tokens| tokens.get(key).map(|record| record.capabilities.clone()))
}
pub fn identity(&self, key: &str) -> Option<crate::audit::Identity> {
self.tokens.read().ok().and_then(|tokens| {
tokens.get(key).map(|record| crate::audit::Identity {
token_id: record.id.clone(),
label: record.label.clone(),
})
})
}
pub fn count(&self) -> usize {
self.tokens.read().map(|t| t.len()).unwrap_or(0)
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub fn extract_key(&self, header_value: &str) -> Option<String> {
if header_value.starts_with(&self.config.prefix) {
Some(header_value[self.config.prefix.len()..].to_string())
} else {
None
}
}
}
impl Default for ApiKeyStore {
fn default() -> Self {
Self::new(AuthConfig::default())
}
}
pub async fn auth_middleware(
State(store): State<std::sync::Arc<ApiKeyStore>>,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
if !store.is_enabled() {
return Ok(next.run(request).await);
}
if request.uri().path() == "/health" {
return Ok(next.run(request).await);
}
let auth_header = request
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok());
match auth_header {
Some(header) => {
if let Some(key) = store.extract_key(header) {
if store.is_valid(&key) {
return Ok(next.run(request).await);
}
}
Err(StatusCode::UNAUTHORIZED)
}
None => Err(StatusCode::UNAUTHORIZED),
}
}
fn generate_token_id() -> String {
let full = generate_api_key();
format!("tok_{}", &full[full.len().saturating_sub(12)..])
}
pub fn generate_api_key() -> String {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("the OS entropy source is unavailable");
let (a, b) = bytes.split_at(8);
format!(
"st_{:016x}_{:016x}",
u64::from_be_bytes(a.try_into().expect("split_at(8) yields 8 bytes")),
u64::from_be_bytes(b.try_into().expect("split_at(8) yields 8 bytes"))
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_config_default() {
let config = AuthConfig::default();
assert!(config.enabled);
assert_eq!(config.prefix, "Bearer ");
}
#[test]
fn test_auth_config_disabled() {
let config = AuthConfig::disabled();
assert!(!config.enabled);
}
#[test]
fn test_api_key_store_add_remove() {
let store = ApiKeyStore::default();
store.add_key("test-key-123");
assert!(store.is_valid("test-key-123"));
assert!(!store.is_valid("invalid-key"));
assert_eq!(store.count(), 1);
assert!(store.remove_key("test-key-123"));
assert!(!store.is_valid("test-key-123"));
assert_eq!(store.count(), 0);
}
#[test]
fn test_api_key_store_extract() {
let store = ApiKeyStore::default();
let key = store.extract_key("Bearer my-secret-key");
assert_eq!(key, Some("my-secret-key".to_string()));
let no_key = store.extract_key("Basic credentials");
assert!(no_key.is_none());
}
#[test]
fn test_api_key_store_disabled() {
let store = ApiKeyStore::disabled();
assert!(!store.is_enabled());
}
#[test]
fn test_generate_api_key() {
let key1 = generate_api_key();
let key2 = generate_api_key();
assert!(key1.starts_with("st_"));
assert!(key2.starts_with("st_"));
assert_ne!(key1, key2);
}
#[test]
fn the_secret_half_is_not_a_function_of_the_printed_half() {
let key = generate_api_key();
let mut halves = key.trim_start_matches("st_").split('_');
let printed = u64::from_str_radix(halves.next().unwrap(), 16).unwrap();
let secret = u64::from_str_radix(halves.next().unwrap(), 16).unwrap();
assert_ne!(
secret,
printed.wrapping_mul(0x5DEECE66D).wrapping_add(0xB),
"the second half of {key} is derived from the first"
);
}
#[test]
fn keys_generated_back_to_back_are_all_distinct() {
let keys: std::collections::HashSet<String> =
(0..1000).map(|_| generate_api_key()).collect();
assert_eq!(keys.len(), 1000);
}
#[test]
fn test_api_key_store_multiple_keys() {
let store = ApiKeyStore::default();
store.add_key("key1");
store.add_key("key2");
store.add_key("key3");
assert_eq!(store.count(), 3);
assert!(store.is_valid("key1"));
assert!(store.is_valid("key2"));
assert!(store.is_valid("key3"));
}
#[test]
fn test_legacy_key_maps_to_full_control() {
let store = ApiKeyStore::default();
store.add_key("legacy-key");
let caps = store.capabilities("legacy-key").expect("token registered");
assert!(caps.is_wildcard());
assert!(caps.satisfies("exec"));
assert!(caps.satisfies("session.manage"));
}
#[test]
fn test_add_key_with_capabilities() {
let store = ApiKeyStore::default();
let caps: CapabilitySet = ["exec", "session.read"].into_iter().collect();
store.add_key_with_capabilities("fine-grained", caps, "operator");
assert!(store.is_valid("fine-grained"));
let caps = store
.capabilities("fine-grained")
.expect("token registered");
assert!(caps.satisfies("exec"));
assert!(caps.satisfies("session.read"));
assert!(!caps.is_wildcard());
assert!(!caps.satisfies("session.manage"));
}
#[test]
fn test_capabilities_of_unknown_key_is_none() {
let store = ApiKeyStore::default();
assert!(store.capabilities("nope").is_none());
}
#[test]
fn test_token_record_full_control() {
let record = TokenRecord::full_control("legacy");
assert!(record.capabilities.is_wildcard());
assert_eq!(record.label, "legacy");
}
}