Skip to main content

oxios_kernel/
auth.rs

1//! API key authentication manager.
2//!
3//! Provides bearer token authentication for the HTTP API.
4//! Keys are stored as SHA-256 hashes for security.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11
12/// Prefix for all generated Oxios API keys.
13const KEY_PREFIX: &str = "oxios_";
14
15/// Metadata about an API key (stored alongside the hash).
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct KeyMeta {
18    /// Human-readable name for the key.
19    pub name: String,
20    /// When the key was created.
21    pub created_at: String,
22    /// When the key was last used (ISO 8601).
23    pub last_used: Option<String>,
24}
25
26/// A stored API key entry (hash + metadata).
27#[derive(Debug, Clone, Serialize, Deserialize)]
28struct KeyEntry {
29    /// SHA-256 hash of the full API key.
30    hash_hex: String,
31    #[serde(flatten)]
32    meta: KeyMeta,
33}
34
35/// API key file format.
36#[derive(Debug, Default, Serialize, Deserialize)]
37struct KeyFile {
38    keys: Vec<KeyEntry>,
39}
40
41/// Manages API key authentication.
42pub struct AuthManager {
43    /// SHA-256 hash → KeyMeta lookup.
44    entries: HashMap<String, KeyMeta>,
45    /// Set of all valid hashes for O(1) lookup.
46    valid_hashes: HashSet<String>,
47    /// Path to persist keys (optional for in-memory-only mode).
48    path: Option<std::path::PathBuf>,
49}
50
51impl AuthManager {
52    /// Create a new AuthManager without persistence.
53    pub fn new() -> Self {
54        Self {
55            entries: HashMap::new(),
56            valid_hashes: HashSet::new(),
57            path: None,
58        }
59    }
60
61    /// Create an AuthManager that persists keys to a file.
62    pub fn with_persistence(path: impl Into<std::path::PathBuf>) -> Result<Self> {
63        let path = path.into();
64        let mut mgr = Self {
65            entries: HashMap::new(),
66            valid_hashes: HashSet::new(),
67            path: Some(path.clone()),
68        };
69        if path.exists() {
70            mgr.load_from_file(&path)?;
71        }
72        Ok(mgr)
73    }
74
75    /// Load keys from a JSON file.
76    pub fn load_from_file(&mut self, path: &Path) -> Result<()> {
77        let content = std::fs::read_to_string(path)
78            .with_context(|| format!("Failed to read API keys from {}", path.display()))?;
79        let key_file: KeyFile =
80            serde_json::from_str(&content).with_context(|| "Failed to parse API keys file")?;
81        for entry in key_file.keys {
82            self.valid_hashes.insert(entry.hash_hex.clone());
83            self.entries.insert(entry.hash_hex, entry.meta);
84        }
85        tracing::info!(count = self.valid_hashes.len(), "Loaded API keys");
86        Ok(())
87    }
88
89    /// Save keys to the persistence file.
90    fn save_to_file(&self) -> Result<()> {
91        if let Some(path) = &self.path {
92            let key_file = KeyFile {
93                keys: self
94                    .entries
95                    .iter()
96                    .map(|(hash, meta)| KeyEntry {
97                        hash_hex: hash.clone(),
98                        meta: meta.clone(),
99                    })
100                    .collect(),
101            };
102            let content = serde_json::to_string_pretty(&key_file)?;
103            // Write atomically via temp file
104            let tmp_path = path.with_extension("tmp");
105            std::fs::write(&tmp_path, &content)?;
106            std::fs::rename(&tmp_path, path)?;
107        }
108        Ok(())
109    }
110
111    /// Generate a new API key.
112    ///
113    /// Returns the full key string (only shown once).
114    pub fn generate_key(&mut self, name: &str) -> Result<String> {
115        let key_bytes = Self::random_key();
116        let full_key = format!("{}{}", KEY_PREFIX, hex::encode(key_bytes));
117        let hash = Self::hash_key(&full_key);
118        let meta = KeyMeta {
119            name: name.to_string(),
120            created_at: chrono::Utc::now().to_rfc3339(),
121            last_used: None,
122        };
123        self.valid_hashes.insert(hash.clone());
124        self.entries.insert(hash, meta);
125        self.save_to_file()?;
126        tracing::info!(name = %name, "Generated new API key");
127        Ok(full_key)
128    }
129
130    /// Validate a bearer token.
131    pub fn validate(&mut self, token: &str) -> bool {
132        let hash = Self::hash_key(token);
133        if self.valid_hashes.contains(&hash) {
134            // Update last_used
135            if let Some(meta) = self.entries.get_mut(&hash) {
136                meta.last_used = Some(chrono::Utc::now().to_rfc3339());
137                let _ = self.save_to_file();
138            }
139            true
140        } else {
141            false
142        }
143    }
144
145    /// Revoke an API key by name.
146    pub fn revoke_key(&mut self, name: &str) -> Result<()> {
147        let hashes_to_remove: Vec<String> = self
148            .entries
149            .iter()
150            .filter(|(_, meta)| meta.name == name)
151            .map(|(hash, _)| hash.clone())
152            .collect();
153        if hashes_to_remove.is_empty() {
154            anyhow::bail!("Key '{}' not found", name);
155        }
156        for hash in hashes_to_remove {
157            self.valid_hashes.remove(&hash);
158            self.entries.remove(&hash);
159        }
160        self.save_to_file()?;
161        tracing::info!(name = %name, "Revoked API key");
162        Ok(())
163    }
164
165    /// List all keys (metadata only, never expose the key itself).
166    pub fn list_keys(&self) -> Vec<&KeyMeta> {
167        self.entries.values().collect()
168    }
169
170    /// Check if any keys are configured.
171    pub fn has_keys(&self) -> bool {
172        !self.valid_hashes.is_empty()
173    }
174
175    /// Hash an API key using SHA-256.
176    fn hash_key(key: &str) -> String {
177        let mut hasher = Sha256::new();
178        hasher.update(key.as_bytes());
179        hex::encode(hasher.finalize())
180    }
181
182    /// Generate random bytes for a new key.
183    fn random_key() -> [u8; 32] {
184        let mut bytes = [0u8; 32];
185        getrandom::getrandom(&mut bytes).expect("failed to generate random bytes");
186        bytes
187    }
188}
189
190impl Default for AuthManager {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn generate_and_validate_key() {
202        let mut mgr = AuthManager::new();
203        let key = mgr.generate_key("test-key").unwrap();
204        assert!(key.starts_with(KEY_PREFIX));
205        assert!(mgr.validate(&key));
206    }
207
208    #[test]
209    fn invalid_key_rejected() {
210        let mut mgr = AuthManager::new();
211        assert!(!mgr.validate("oxios_invalidkey"));
212    }
213
214    #[test]
215    fn revoke_key() {
216        let mut mgr = AuthManager::new();
217        let key = mgr.generate_key("to-revoke").unwrap();
218        assert!(mgr.validate(&key));
219        mgr.revoke_key("to-revoke").unwrap();
220        assert!(!mgr.validate(&key));
221    }
222
223    #[test]
224    fn revoke_nonexistent_key_fails() {
225        let mut mgr = AuthManager::new();
226        assert!(mgr.revoke_key("no-such-key").is_err());
227    }
228
229    #[test]
230    fn has_keys_reflects_state() {
231        let mut mgr = AuthManager::new();
232        assert!(!mgr.has_keys());
233        mgr.generate_key("first").unwrap();
234        assert!(mgr.has_keys());
235    }
236
237    #[test]
238    fn list_keys_returns_metadata() {
239        let mut mgr = AuthManager::new();
240        mgr.generate_key("alpha").unwrap();
241        mgr.generate_key("beta").unwrap();
242        let names: Vec<&str> = mgr.list_keys().iter().map(|m| m.name.as_str()).collect();
243        assert!(names.contains(&"alpha"));
244        assert!(names.contains(&"beta"));
245    }
246
247    #[test]
248    fn persistence_roundtrip() {
249        let dir = tempfile::tempdir().unwrap();
250        let path = dir.path().join("keys.json");
251
252        let key = {
253            let mut mgr = AuthManager::with_persistence(&path).unwrap();
254            mgr.generate_key("persist-test").unwrap()
255        };
256
257        // Load from file in a fresh manager
258        let mut mgr2 = AuthManager::with_persistence(&path).unwrap();
259        assert!(mgr2.validate(&key));
260        assert!(mgr2.has_keys());
261    }
262
263    #[test]
264    fn hash_is_deterministic() {
265        let h1 = AuthManager::hash_key("oxios_test123");
266        let h2 = AuthManager::hash_key("oxios_test123");
267        assert_eq!(h1, h2);
268    }
269}