use std::collections::HashMap;
use std::sync::RwLock;
use tracing::info;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AuthApiKey {
pub key_id: String,
pub secret_hash: Vec<u8>,
pub auth_user_id: String,
pub tenant_id: u32,
pub scopes: Vec<String>,
pub rate_limit_qps: u64,
pub rate_limit_burst: u64,
pub expires_at: u64,
pub created_at: u64,
pub is_revoked: bool,
pub last_used_at: u64,
pub last_used_ip: String,
pub replaces_key_id: Option<String>,
pub overlap_ends_at: u64,
}
pub struct AuthApiKeyStore {
keys: RwLock<HashMap<String, AuthApiKey>>,
hash_index: RwLock<HashMap<String, String>>,
}
impl AuthApiKeyStore {
pub fn new() -> Self {
Self {
keys: RwLock::new(HashMap::new()),
hash_index: RwLock::new(HashMap::new()),
}
}
pub fn create_key(
&self,
auth_user_id: &str,
tenant_id: u32,
scopes: Vec<String>,
rate_limit_qps: u64,
rate_limit_burst: u64,
expires_days: u64,
) -> String {
let key_id = generate_key_id();
let secret = generate_secret();
let token = format!("nda_{key_id}_{secret}");
let secret_hash = hash_secret(&secret);
let now = now_secs();
let expires_at = if expires_days > 0 {
now + expires_days * 86_400
} else {
0
};
let record = AuthApiKey {
key_id: key_id.clone(),
secret_hash: secret_hash.clone(),
auth_user_id: auth_user_id.into(),
tenant_id,
scopes,
rate_limit_qps,
rate_limit_burst,
expires_at,
created_at: now,
is_revoked: false,
last_used_at: 0,
last_used_ip: String::new(),
replaces_key_id: None,
overlap_ends_at: 0,
};
let hash_hex = hex_encode(&secret_hash);
let mut keys = self.keys.write().unwrap_or_else(|p| p.into_inner());
let mut idx = self.hash_index.write().unwrap_or_else(|p| p.into_inner());
keys.insert(key_id.clone(), record);
idx.insert(hash_hex, key_id);
token
}
pub fn verify(&self, token: &str) -> Option<AuthApiKey> {
let stripped = token.strip_prefix("nda_")?;
let parts: Vec<&str> = stripped.splitn(2, '_').collect();
if parts.len() != 2 {
return None;
}
let secret = parts[1];
let secret_hash = hash_secret(secret);
let hash_hex = hex_encode(&secret_hash);
let idx = self.hash_index.read().unwrap_or_else(|p| p.into_inner());
let key_id = idx.get(&hash_hex)?;
let keys = self.keys.read().unwrap_or_else(|p| p.into_inner());
let key = keys.get(key_id)?;
if key.is_revoked {
return None;
}
if key.expires_at > 0 && now_secs() > key.expires_at {
return None;
}
Some(key.clone())
}
pub fn touch(&self, key_id: &str, ip: &str) {
let mut keys = self.keys.write().unwrap_or_else(|p| p.into_inner());
if let Some(key) = keys.get_mut(key_id) {
key.last_used_at = now_secs();
key.last_used_ip = ip.to_string();
}
}
pub fn revoke(&self, key_id: &str) -> bool {
let mut keys = self.keys.write().unwrap_or_else(|p| p.into_inner());
if let Some(key) = keys.get_mut(key_id) {
key.is_revoked = true;
true
} else {
false
}
}
pub fn rotate(&self, old_key_id: &str, overlap_hours: u64) -> Option<String> {
let keys = self.keys.read().unwrap_or_else(|p| p.into_inner());
let old = keys.get(old_key_id)?.clone();
drop(keys);
let new_token = self.create_key(
&old.auth_user_id,
old.tenant_id,
old.scopes.clone(),
old.rate_limit_qps,
old.rate_limit_burst,
0, );
let new_key_id = new_token
.strip_prefix("nda_")
.and_then(|s| s.split('_').next())
.unwrap_or("")
.to_string();
let overlap_ends = now_secs() + overlap_hours * 3600;
let mut keys = self.keys.write().unwrap_or_else(|p| p.into_inner());
if let Some(new_key) = keys.get_mut(&new_key_id) {
new_key.replaces_key_id = Some(old_key_id.to_string());
new_key.overlap_ends_at = overlap_ends;
}
info!(
old_key = %old_key_id,
new_key = %new_key_id,
overlap_hours,
"auth API key rotated"
);
Some(new_token)
}
pub fn list_for_user(&self, auth_user_id: &str) -> Vec<AuthApiKey> {
let keys = self.keys.read().unwrap_or_else(|p| p.into_inner());
keys.values()
.filter(|k| k.auth_user_id == auth_user_id && !k.is_revoked)
.cloned()
.collect()
}
pub fn list_all(&self) -> Vec<AuthApiKey> {
let keys = self.keys.read().unwrap_or_else(|p| p.into_inner());
keys.values().filter(|k| !k.is_revoked).cloned().collect()
}
}
impl Default for AuthApiKeyStore {
fn default() -> Self {
Self::new()
}
}
fn generate_key_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let ts = now_secs();
format!("{ts:x}{seq:04x}")
}
fn generate_secret() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut random_bytes = [0u8; 32];
getrandom::fill(&mut random_bytes).unwrap_or_else(|_| {
let ts = now_secs();
random_bytes[..8].copy_from_slice(&ts.to_le_bytes());
random_bytes[8..16].copy_from_slice(&seq.to_le_bytes());
});
format!("{}{seq:08x}", hex_encode(&random_bytes))
}
fn hash_secret(secret: &str) -> Vec<u8> {
use sha2::{Digest, Sha256};
Sha256::digest(secret.as_bytes()).to_vec()
}
fn now_secs() -> u64 {
crate::control::security::time::now_secs()
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_and_verify() {
let store = AuthApiKeyStore::new();
let token = store.create_key("user_42", 1, vec![], 0, 0, 0);
assert!(token.starts_with("nda_"));
let key = store.verify(&token).unwrap();
assert_eq!(key.auth_user_id, "user_42");
assert_eq!(key.tenant_id, 1);
}
#[test]
fn invalid_token_rejected() {
let store = AuthApiKeyStore::new();
assert!(store.verify("nda_bad_token").is_none());
assert!(store.verify("ndb_wrong_prefix").is_none());
}
#[test]
fn revoked_key_rejected() {
let store = AuthApiKeyStore::new();
let token = store.create_key("u1", 1, vec![], 0, 0, 0);
let key = store.verify(&token).unwrap();
store.revoke(&key.key_id);
assert!(store.verify(&token).is_none());
}
#[test]
fn scoped_key() {
let store = AuthApiKeyStore::new();
let token = store.create_key(
"u1",
1,
vec!["profile:read".into(), "orders:write".into()],
100,
200,
30,
);
let key = store.verify(&token).unwrap();
assert_eq!(key.scopes, vec!["profile:read", "orders:write"]);
assert_eq!(key.rate_limit_qps, 100);
assert!(key.expires_at > 0);
}
#[test]
fn last_used_tracking() {
let store = AuthApiKeyStore::new();
let token = store.create_key("u1", 1, vec![], 0, 0, 0);
let key = store.verify(&token).unwrap();
assert_eq!(key.last_used_at, 0);
store.touch(&key.key_id, "10.0.0.1");
let key2 = store.verify(&token).unwrap();
assert!(key2.last_used_at > 0);
assert_eq!(key2.last_used_ip, "10.0.0.1");
}
#[test]
fn key_rotation() {
let store = AuthApiKeyStore::new();
let old_token = store.create_key("u1", 1, vec!["scope_a".into()], 0, 0, 0);
let old_key = store.verify(&old_token).unwrap();
let new_token = store.rotate(&old_key.key_id, 24).unwrap();
assert!(new_token.starts_with("nda_"));
assert!(store.verify(&old_token).is_some());
assert!(store.verify(&new_token).is_some());
let new_key = store.verify(&new_token).unwrap();
assert_eq!(new_key.scopes, vec!["scope_a"]);
assert!(new_key.replaces_key_id.is_some());
}
#[test]
fn list_for_user() {
let store = AuthApiKeyStore::new();
store.create_key("u1", 1, vec![], 0, 0, 0);
store.create_key("u1", 1, vec![], 0, 0, 0);
store.create_key("u2", 1, vec![], 0, 0, 0);
assert_eq!(store.list_for_user("u1").len(), 2);
assert_eq!(store.list_for_user("u2").len(), 1);
}
}