1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11
12const KEY_PREFIX: &str = "oxios_";
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct KeyMeta {
18 pub name: String,
20 pub created_at: String,
22 pub last_used: Option<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28struct KeyEntry {
29 hash_hex: String,
31 #[serde(flatten)]
32 meta: KeyMeta,
33}
34
35#[derive(Debug, Default, Serialize, Deserialize)]
37struct KeyFile {
38 keys: Vec<KeyEntry>,
39}
40
41pub struct AuthManager {
43 entries: HashMap<String, KeyMeta>,
45 valid_hashes: HashSet<String>,
47 path: Option<std::path::PathBuf>,
49 last_flush: Option<std::time::Instant>,
56}
57
58impl AuthManager {
59 pub fn new() -> Self {
61 Self {
62 entries: HashMap::new(),
63 valid_hashes: HashSet::new(),
64 path: None,
65 last_flush: None,
66 }
67 }
68 pub fn with_persistence(path: impl Into<std::path::PathBuf>) -> Result<Self> {
70 let path = path.into();
71 let mut mgr = Self {
72 entries: HashMap::new(),
73 valid_hashes: HashSet::new(),
74 path: Some(path.clone()),
75 last_flush: None,
76 };
77 if path.exists() {
78 mgr.load_from_file(&path)?;
79 }
80 Ok(mgr)
81 }
82
83 pub fn load_from_file(&mut self, path: &Path) -> Result<()> {
85 let content = std::fs::read_to_string(path)
86 .with_context(|| format!("Failed to read API keys from {}", path.display()))?;
87 let key_file: KeyFile =
88 serde_json::from_str(&content).with_context(|| "Failed to parse API keys file")?;
89 for entry in key_file.keys {
90 self.valid_hashes.insert(entry.hash_hex.clone());
91 self.entries.insert(entry.hash_hex, entry.meta);
92 }
93 tracing::info!(count = self.valid_hashes.len(), "Loaded API keys");
94 Ok(())
95 }
96
97 fn save_to_file(&self) -> Result<()> {
99 if let Some(path) = &self.path {
100 let key_file = KeyFile {
101 keys: self
102 .entries
103 .iter()
104 .map(|(hash, meta)| KeyEntry {
105 hash_hex: hash.clone(),
106 meta: meta.clone(),
107 })
108 .collect(),
109 };
110 let content = serde_json::to_string_pretty(&key_file)?;
111 let tmp_path = path.with_extension("tmp");
115 write_secret_file(&tmp_path, &content)?;
116 std::fs::rename(&tmp_path, path)?;
117 }
118 Ok(())
119 }
120
121 pub fn generate_key(&mut self, name: &str) -> Result<String> {
125 let key_bytes = Self::random_key();
126 let full_key = format!("{}{}", KEY_PREFIX, hex::encode(key_bytes));
127 let hash = Self::hash_key(&full_key);
128 let meta = KeyMeta {
129 name: name.to_string(),
130 created_at: chrono::Utc::now().to_rfc3339(),
131 last_used: None,
132 };
133 self.valid_hashes.insert(hash.clone());
134 self.entries.insert(hash, meta);
135 self.save_to_file()?;
136 tracing::info!(name = %name, "Generated new API key");
137 Ok(full_key)
138 }
139
140 pub fn validate(&mut self, token: &str) -> bool {
146 let hash = Self::hash_key(token);
147 if self.valid_hashes.contains(&hash) {
148 if let Some(meta) = self.entries.get_mut(&hash) {
149 meta.last_used = Some(chrono::Utc::now().to_rfc3339());
150 let should_flush = self
151 .last_flush
152 .map(|t| t.elapsed() >= std::time::Duration::from_secs(60))
153 .unwrap_or(true);
154 if should_flush && self.save_to_file().is_ok() {
155 self.last_flush = Some(std::time::Instant::now());
156 }
157 }
158 true
159 } else {
160 false
161 }
162 }
163
164 pub fn revoke_key(&mut self, name: &str) -> Result<()> {
166 let hashes_to_remove: Vec<String> = self
167 .entries
168 .iter()
169 .filter(|(_, meta)| meta.name == name)
170 .map(|(hash, _)| hash.clone())
171 .collect();
172 if hashes_to_remove.is_empty() {
173 anyhow::bail!("Key '{name}' not found");
174 }
175 for hash in hashes_to_remove {
176 self.valid_hashes.remove(&hash);
177 self.entries.remove(&hash);
178 }
179 self.save_to_file()?;
180 tracing::info!(name = %name, "Revoked API key");
181 Ok(())
182 }
183
184 pub fn list_keys(&self) -> Vec<&KeyMeta> {
186 self.entries.values().collect()
187 }
188
189 pub fn has_keys(&self) -> bool {
191 !self.valid_hashes.is_empty()
192 }
193
194 fn hash_key(key: &str) -> String {
202 let mut hasher = Sha256::new();
203 hasher.update(key.as_bytes());
204 hex::encode(hasher.finalize())
205 }
206
207 fn random_key() -> [u8; 32] {
209 let mut bytes = [0u8; 32];
210 getrandom::getrandom(&mut bytes).expect("failed to generate random bytes");
211 bytes
212 }
213}
214
215fn write_secret_file(path: &std::path::Path, content: &str) -> Result<()> {
220 #[cfg(unix)]
221 {
222 use std::io::Write;
223 use std::os::unix::fs::OpenOptionsExt;
224 let mut f = std::fs::OpenOptions::new()
225 .write(true)
226 .create(true)
227 .truncate(true)
228 .mode(0o600)
229 .open(path)?;
230 f.write_all(content.as_bytes())?;
231 Ok(())
232 }
233 #[cfg(not(unix))]
234 {
235 std::fs::write(path, content)?;
236 Ok(())
237 }
238}
239
240impl Default for AuthManager {
241 fn default() -> Self {
242 Self::new()
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn generate_and_validate_key() {
252 let mut mgr = AuthManager::new();
253 let key = mgr.generate_key("test-key").unwrap();
254 assert!(key.starts_with(KEY_PREFIX));
255 assert!(mgr.validate(&key));
256 }
257
258 #[test]
259 fn invalid_key_rejected() {
260 let mut mgr = AuthManager::new();
261 assert!(!mgr.validate("oxios_invalidkey"));
262 }
263
264 #[test]
265 fn revoke_key() {
266 let mut mgr = AuthManager::new();
267 let key = mgr.generate_key("to-revoke").unwrap();
268 assert!(mgr.validate(&key));
269 mgr.revoke_key("to-revoke").unwrap();
270 assert!(!mgr.validate(&key));
271 }
272
273 #[test]
274 fn revoke_nonexistent_key_fails() {
275 let mut mgr = AuthManager::new();
276 assert!(mgr.revoke_key("no-such-key").is_err());
277 }
278
279 #[test]
280 fn has_keys_reflects_state() {
281 let mut mgr = AuthManager::new();
282 assert!(!mgr.has_keys());
283 mgr.generate_key("first").unwrap();
284 assert!(mgr.has_keys());
285 }
286
287 #[test]
288 fn list_keys_returns_metadata() {
289 let mut mgr = AuthManager::new();
290 mgr.generate_key("alpha").unwrap();
291 mgr.generate_key("beta").unwrap();
292 let names: Vec<&str> = mgr.list_keys().iter().map(|m| m.name.as_str()).collect();
293 assert!(names.contains(&"alpha"));
294 assert!(names.contains(&"beta"));
295 }
296
297 #[test]
298 fn persistence_roundtrip() {
299 let dir = tempfile::tempdir().unwrap();
300 let path = dir.path().join("keys.json");
301
302 let key = {
303 let mut mgr = AuthManager::with_persistence(&path).unwrap();
304 mgr.generate_key("persist-test").unwrap()
305 };
306
307 let mut mgr2 = AuthManager::with_persistence(&path).unwrap();
309 assert!(mgr2.validate(&key));
310 assert!(mgr2.has_keys());
311 }
312
313 #[test]
314 fn hash_is_deterministic() {
315 let h1 = AuthManager::hash_key("oxios_test123");
316 let h2 = AuthManager::hash_key("oxios_test123");
317 assert_eq!(h1, h2);
318 }
319}