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}
50
51impl AuthManager {
52 pub fn new() -> Self {
54 Self {
55 entries: HashMap::new(),
56 valid_hashes: HashSet::new(),
57 path: None,
58 }
59 }
60
61 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 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 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 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 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 pub fn validate(&mut self, token: &str) -> bool {
132 let hash = Self::hash_key(token);
133 if self.valid_hashes.contains(&hash) {
134 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 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 pub fn list_keys(&self) -> Vec<&KeyMeta> {
167 self.entries.values().collect()
168 }
169
170 pub fn has_keys(&self) -> bool {
172 !self.valid_hashes.is_empty()
173 }
174
175 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 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 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}