use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::path::Path;
const KEY_PREFIX: &str = "oxios_";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyMeta {
pub name: String,
pub created_at: String,
pub last_used: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct KeyEntry {
hash_hex: String,
#[serde(flatten)]
meta: KeyMeta,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct KeyFile {
keys: Vec<KeyEntry>,
}
pub struct AuthManager {
entries: HashMap<String, KeyMeta>,
valid_hashes: HashSet<String>,
path: Option<std::path::PathBuf>,
}
impl AuthManager {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
valid_hashes: HashSet::new(),
path: None,
}
}
pub fn with_persistence(path: impl Into<std::path::PathBuf>) -> Result<Self> {
let path = path.into();
let mut mgr = Self {
entries: HashMap::new(),
valid_hashes: HashSet::new(),
path: Some(path.clone()),
};
if path.exists() {
mgr.load_from_file(&path)?;
}
Ok(mgr)
}
pub fn load_from_file(&mut self, path: &Path) -> Result<()> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read API keys from {}", path.display()))?;
let key_file: KeyFile =
serde_json::from_str(&content).with_context(|| "Failed to parse API keys file")?;
for entry in key_file.keys {
self.valid_hashes.insert(entry.hash_hex.clone());
self.entries.insert(entry.hash_hex, entry.meta);
}
tracing::info!(count = self.valid_hashes.len(), "Loaded API keys");
Ok(())
}
fn save_to_file(&self) -> Result<()> {
if let Some(path) = &self.path {
let key_file = KeyFile {
keys: self
.entries
.iter()
.map(|(hash, meta)| KeyEntry {
hash_hex: hash.clone(),
meta: meta.clone(),
})
.collect(),
};
let content = serde_json::to_string_pretty(&key_file)?;
let tmp_path = path.with_extension("tmp");
std::fs::write(&tmp_path, &content)?;
std::fs::rename(&tmp_path, path)?;
}
Ok(())
}
pub fn generate_key(&mut self, name: &str) -> Result<String> {
let key_bytes = Self::random_key();
let full_key = format!("{}{}", KEY_PREFIX, hex::encode(key_bytes));
let hash = Self::hash_key(&full_key);
let meta = KeyMeta {
name: name.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
last_used: None,
};
self.valid_hashes.insert(hash.clone());
self.entries.insert(hash, meta);
self.save_to_file()?;
tracing::info!(name = %name, "Generated new API key");
Ok(full_key)
}
pub fn validate(&mut self, token: &str) -> bool {
let hash = Self::hash_key(token);
if self.valid_hashes.contains(&hash) {
if let Some(meta) = self.entries.get_mut(&hash) {
meta.last_used = Some(chrono::Utc::now().to_rfc3339());
let _ = self.save_to_file();
}
true
} else {
false
}
}
pub fn revoke_key(&mut self, name: &str) -> Result<()> {
let hashes_to_remove: Vec<String> = self
.entries
.iter()
.filter(|(_, meta)| meta.name == name)
.map(|(hash, _)| hash.clone())
.collect();
if hashes_to_remove.is_empty() {
anyhow::bail!("Key '{name}' not found");
}
for hash in hashes_to_remove {
self.valid_hashes.remove(&hash);
self.entries.remove(&hash);
}
self.save_to_file()?;
tracing::info!(name = %name, "Revoked API key");
Ok(())
}
pub fn list_keys(&self) -> Vec<&KeyMeta> {
self.entries.values().collect()
}
pub fn has_keys(&self) -> bool {
!self.valid_hashes.is_empty()
}
fn hash_key(key: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(key.as_bytes());
hex::encode(hasher.finalize())
}
fn random_key() -> [u8; 32] {
let mut bytes = [0u8; 32];
getrandom::getrandom(&mut bytes).expect("failed to generate random bytes");
bytes
}
}
impl Default for AuthManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_and_validate_key() {
let mut mgr = AuthManager::new();
let key = mgr.generate_key("test-key").unwrap();
assert!(key.starts_with(KEY_PREFIX));
assert!(mgr.validate(&key));
}
#[test]
fn invalid_key_rejected() {
let mut mgr = AuthManager::new();
assert!(!mgr.validate("oxios_invalidkey"));
}
#[test]
fn revoke_key() {
let mut mgr = AuthManager::new();
let key = mgr.generate_key("to-revoke").unwrap();
assert!(mgr.validate(&key));
mgr.revoke_key("to-revoke").unwrap();
assert!(!mgr.validate(&key));
}
#[test]
fn revoke_nonexistent_key_fails() {
let mut mgr = AuthManager::new();
assert!(mgr.revoke_key("no-such-key").is_err());
}
#[test]
fn has_keys_reflects_state() {
let mut mgr = AuthManager::new();
assert!(!mgr.has_keys());
mgr.generate_key("first").unwrap();
assert!(mgr.has_keys());
}
#[test]
fn list_keys_returns_metadata() {
let mut mgr = AuthManager::new();
mgr.generate_key("alpha").unwrap();
mgr.generate_key("beta").unwrap();
let names: Vec<&str> = mgr.list_keys().iter().map(|m| m.name.as_str()).collect();
assert!(names.contains(&"alpha"));
assert!(names.contains(&"beta"));
}
#[test]
fn persistence_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("keys.json");
let key = {
let mut mgr = AuthManager::with_persistence(&path).unwrap();
mgr.generate_key("persist-test").unwrap()
};
let mut mgr2 = AuthManager::with_persistence(&path).unwrap();
assert!(mgr2.validate(&key));
assert!(mgr2.has_keys());
}
#[test]
fn hash_is_deterministic() {
let h1 = AuthManager::hash_key("oxios_test123");
let h2 = AuthManager::hash_key("oxios_test123");
assert_eq!(h1, h2);
}
}