1use crate::error::{AuthError, Result};
9use argon2::password_hash::rand_core::OsRng;
10use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
11use argon2::{Algorithm, Argon2, Params, Version};
12use base64::Engine as _;
13use parking_lot::RwLock;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::time::Duration;
17
18fn argon2() -> Result<Argon2<'static>> {
28 let params = Params::new(64 * 1024, 3, 4, None)
29 .map_err(|e| AuthError::Other(format!("argon2 params: {e}")))?;
30 Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
31}
32
33#[cfg(test)]
38fn api_key_test_lock() -> &'static parking_lot::Mutex<()> {
39 static LOCK: std::sync::OnceLock<parking_lot::Mutex<()>> = std::sync::OnceLock::new();
40 LOCK.get_or_init(|| parking_lot::Mutex::new(()))
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ApiKey {
46 pub id: String,
47 pub prefix: String,
48 pub secret_hash: String,
50 pub scopes: Vec<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub identity: Option<ApiKeyIdentity>,
56 pub created_at: i64,
57 pub expires_at: Option<i64>,
58 pub last_used_at: Option<i64>,
59 pub revoked_at: Option<i64>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ApiKeyIdentity {
65 pub subject_type: String,
66 pub subject_id: String,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub tenant_id: Option<String>,
69}
70
71impl ApiKeyIdentity {
72 pub fn new(
73 subject_type: impl Into<String>,
74 subject_id: impl Into<String>,
75 tenant_id: Option<String>,
76 ) -> Result<Self> {
77 let subject_type = subject_type.into();
78 let subject_id = subject_id.into();
79 if !matches!(subject_type.as_str(), "User" | "Agent") || subject_id.trim().is_empty() {
80 return Err(AuthError::Other(
81 "API key identity must use User or Agent with a non-empty subject id".into(),
82 ));
83 }
84 if tenant_id
85 .as_ref()
86 .is_some_and(|tenant| tenant.trim().is_empty())
87 {
88 return Err(AuthError::Other(
89 "API key tenant id must be non-empty when provided".into(),
90 ));
91 }
92 let subject_id = subject_id.trim().to_owned();
93 let tenant_id = tenant_id.map(|tenant| tenant.trim().to_owned());
94 Ok(Self {
95 subject_type,
96 subject_id,
97 tenant_id,
98 })
99 }
100}
101
102impl ApiKey {
103 pub fn has_scope(&self, required: &str) -> bool {
105 required.is_empty()
106 || self
107 .scopes
108 .iter()
109 .any(|scope| scope == "*" || scope == required)
110 }
111}
112
113#[derive(Debug, Default)]
115pub struct ApiKeyStore {
116 keys: RwLock<HashMap<String, ApiKey>>,
117}
118
119impl ApiKeyStore {
120 pub fn new() -> Self {
121 Self::default()
122 }
123
124 pub fn load_from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
126 let s = Self::new();
127 s.reload_from_file(path)?;
128 Ok(s)
129 }
130
131 pub fn reload_from_file(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
135 let path = path.as_ref();
136 let keys = if path.exists() {
137 let text = std::fs::read_to_string(path)
138 .map_err(|e| AuthError::Other(format!("read: {}", e)))?;
139 let parsed: Vec<ApiKey> = serde_json::from_str(&text)
140 .map_err(|e| AuthError::Other(format!("parse: {}", e)))?;
141 let mut keys = HashMap::with_capacity(parsed.len());
142 for key in parsed {
143 validate_api_key_record(&key)?;
144 let id = key.id.clone();
145 if keys.insert(id.clone(), key).is_some() {
146 return Err(AuthError::Other(format!(
147 "duplicate API-key id in store: {id}"
148 )));
149 }
150 }
151 keys
152 } else {
153 HashMap::new()
154 };
155 *self.keys.write() = keys;
156 Ok(())
157 }
158
159 pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
161 use std::io::Write;
162
163 let path = path.as_ref();
164 let guard = self.keys.read();
165 let mut keys: Vec<ApiKey> = guard.values().cloned().collect();
166 drop(guard);
167 keys.sort_by(|left, right| {
168 left.created_at
169 .cmp(&right.created_at)
170 .then(left.id.cmp(&right.id))
171 });
172 let text = serde_json::to_string_pretty(&keys)
173 .map_err(|e| AuthError::Other(format!("serialize: {}", e)))?;
174 let parent = path
175 .parent()
176 .filter(|p| !p.as_os_str().is_empty())
177 .unwrap_or_else(|| std::path::Path::new("."));
178 let name = path
179 .file_name()
180 .ok_or_else(|| AuthError::Other("API-key store path has no filename".into()))?;
181 let temporary = parent.join(format!(
182 ".{}.{}.tmp",
183 name.to_string_lossy(),
184 uuid::Uuid::new_v4()
185 ));
186
187 let write_result = (|| -> std::io::Result<()> {
188 let mut options = std::fs::OpenOptions::new();
189 options.write(true).create_new(true);
190 #[cfg(unix)]
191 {
192 use std::os::unix::fs::OpenOptionsExt;
193 options.mode(0o600);
194 }
195 let mut file = options.open(&temporary)?;
196 file.write_all(text.as_bytes())?;
197 file.sync_all()?;
198 #[cfg(windows)]
199 if path.exists() {
200 std::fs::remove_file(path)?;
201 }
202 std::fs::rename(&temporary, path)?;
203 #[cfg(unix)]
204 std::fs::File::open(parent)?.sync_all()?;
205 Ok(())
206 })();
207 if let Err(error) = write_result {
208 let _ = std::fs::remove_file(&temporary);
209 return Err(AuthError::Other(format!("write: {}", error)));
210 }
211 Ok(())
212 }
213
214 pub fn create(
231 &self,
232 prefix: impl Into<String>,
233 scopes: Vec<String>,
234 ttl: Option<Duration>,
235 ) -> Result<(ApiKey, String)> {
236 self.create_with_identity(prefix.into(), scopes, ttl, None)
237 }
238
239 pub fn create_bound(
243 &self,
244 prefix: impl Into<String>,
245 scopes: Vec<String>,
246 ttl: Option<Duration>,
247 identity: ApiKeyIdentity,
248 ) -> Result<(ApiKey, String)> {
249 let identity = ApiKeyIdentity::new(
250 identity.subject_type,
251 identity.subject_id,
252 identity.tenant_id,
253 )?;
254 self.create_with_identity(prefix.into(), scopes, ttl, Some(identity))
255 }
256
257 fn create_with_identity(
258 &self,
259 prefix: String,
260 scopes: Vec<String>,
261 ttl: Option<Duration>,
262 identity: Option<ApiKeyIdentity>,
263 ) -> Result<(ApiKey, String)> {
264 if prefix.trim().is_empty() || prefix.trim() != prefix || prefix.contains(':') {
265 return Err(AuthError::Other(
266 "API key prefix must be non-empty and must not contain ':'".into(),
267 ));
268 }
269 if scopes.iter().any(|scope| !valid_scope(scope)) {
270 return Err(AuthError::Other(
271 "API-key scopes must be non-empty and contain no whitespace".into(),
272 ));
273 }
274 let id = uuid::Uuid::new_v4().to_string();
275 let secret_bytes: [u8; 32] = {
276 use argon2::password_hash::rand_core::RngCore;
277 let mut buf = [0u8; 32];
278 OsRng.fill_bytes(&mut buf);
279 buf
280 };
281 let secret_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret_bytes);
282 let salt = SaltString::generate(&mut OsRng);
283 let argon = argon2()?;
284 let secret_hash = argon
285 .hash_password(secret_bytes.as_ref(), &salt)
286 .map_err(|e| AuthError::Other(format!("argon2: {}", e)))?
287 .to_string();
288
289 let now = chrono::Utc::now().timestamp();
290 let expires_at = match ttl {
291 Some(duration) => {
292 let seconds = i64::try_from(duration.as_secs()).map_err(|_| {
293 AuthError::Other("API key TTL exceeds the supported timestamp range".into())
294 })?;
295 Some(now.checked_add(seconds).ok_or_else(|| {
296 AuthError::Other("API key expiry exceeds the supported timestamp range".into())
297 })?)
298 }
299 None => None,
300 };
301
302 let key = ApiKey {
303 id: id.clone(),
304 prefix: prefix.clone(),
305 secret_hash,
306 scopes,
307 identity,
308 created_at: now,
309 expires_at,
310 last_used_at: None,
311 revoked_at: None,
312 };
313 self.keys.write().insert(id, key.clone());
314 let raw = format!("{}:{}:{}", prefix, key.id, secret_b64);
315 Ok((key, raw))
316 }
317
318 pub fn verify(&self, raw: &str) -> Result<ApiKey> {
324 let parts: Vec<&str> = raw.split(':').collect();
325 if parts.len() != 3 {
326 return Err(AuthError::ApiKeyInvalid);
327 }
328 let (prefix, id, secret_b64) = (parts[0], parts[1], parts[2]);
329
330 let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD
331 .decode(secret_b64)
332 .map_err(|_| AuthError::ApiKeyInvalid)?;
333 let guard = self.keys.read();
334 let key = guard.get(id).ok_or(AuthError::ApiKeyInvalid)?.clone();
335 drop(guard);
336 if key.prefix != prefix {
337 return Err(AuthError::Other(format!(
338 "prefix mismatch: {} != {}",
339 key.prefix, prefix
340 )));
341 }
342 if let Some(exp) = key.expires_at {
343 if exp <= chrono::Utc::now().timestamp() {
344 return Err(AuthError::ApiKeyExpired);
345 }
346 }
347 if key.revoked_at.is_some() {
348 return Err(AuthError::ApiKeyRevoked);
349 }
350 let parsed = PasswordHash::new(&key.secret_hash)
351 .map_err(|e| AuthError::Other(format!("hash parse: {}", e)))?;
352 if argon2()?.verify_password(&secret, &parsed).is_err() {
353 return Err(AuthError::ApiKeyInvalid);
354 }
355 Ok(key)
356 }
357
358 pub fn revoke(&self, id: &str) -> Result<()> {
360 let mut guard = self.keys.write();
361 let key = guard.get_mut(id).ok_or(AuthError::ApiKeyInvalid)?;
362 key.revoked_at = Some(chrono::Utc::now().timestamp());
363 Ok(())
364 }
365
366 pub fn list(&self) -> Vec<ApiKey> {
368 self.keys.read().values().cloned().collect()
369 }
370}
371
372fn validate_api_key_record(key: &ApiKey) -> Result<()> {
377 if key.id.trim().is_empty() || key.id.trim() != key.id || key.id.contains(':') {
378 return Err(AuthError::Other(
379 "API-key id must be non-empty and contain no ':'".into(),
380 ));
381 }
382 if key.prefix.trim().is_empty() || key.prefix.trim() != key.prefix || key.prefix.contains(':') {
383 return Err(AuthError::Other(
384 "API-key prefix must be non-empty and contain no ':'".into(),
385 ));
386 }
387 if key.scopes.iter().any(|scope| !valid_scope(scope)) {
388 return Err(AuthError::Other(
389 "API-key scopes must be non-empty and contain no whitespace".into(),
390 ));
391 }
392 if let Some(identity) = &key.identity {
393 let normalized = ApiKeyIdentity::new(
394 identity.subject_type.clone(),
395 identity.subject_id.clone(),
396 identity.tenant_id.clone(),
397 )?;
398 if &normalized != identity {
399 return Err(AuthError::Other(
400 "API-key identity fields must be trimmed".into(),
401 ));
402 }
403 }
404
405 let hash = PasswordHash::new(&key.secret_hash)
406 .map_err(|_| AuthError::Other("API-key store contains an invalid password hash".into()))?;
407 let params = &hash.params;
408 let memory_kib = params.get_decimal("m");
409 let iterations = params.get_decimal("t");
410 let parallelism = params.get_decimal("p");
411 if hash.algorithm.as_str() != "argon2id"
412 || hash.version != Some(19)
413 || hash.salt.is_none()
414 || hash.hash.is_none()
415 || memory_kib.is_none_or(|value| !(8..=65_536).contains(&value))
416 || iterations.is_none_or(|value| !(1..=3).contains(&value))
417 || parallelism.is_none_or(|value| !(1..=4).contains(&value))
418 || memory_kib.unwrap_or_default() < parallelism.unwrap_or_default() * 8
419 {
420 return Err(AuthError::Other(
421 "API-key hash must use bounded Argon2id v19 parameters".into(),
422 ));
423 }
424 Ok(())
425}
426
427fn valid_scope(scope: &str) -> bool {
428 !scope.trim().is_empty() && !scope.bytes().any(|byte| byte.is_ascii_whitespace())
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn create_and_verify_roundtrip() {
437 let _guard = api_key_test_lock().lock();
438 let s = ApiKeyStore::new();
439 let (key, raw) = s
440 .create("ag_live_roundtrip", vec!["read".into()], None)
441 .unwrap();
442 assert_eq!(key.prefix, "ag_live_roundtrip");
443 let verified = s.verify(&raw).unwrap();
444 assert_eq!(verified.id, key.id);
445 }
446
447 #[test]
448 fn bound_key_persists_identity_and_scope() {
449 let _guard = api_key_test_lock().lock();
450 let store = ApiKeyStore::new();
451 let identity = ApiKeyIdentity::new("Agent", "research", Some("tenant-a".into())).unwrap();
452 let (created, raw) = store
453 .create_bound("ag", vec!["authorize".into()], None, identity.clone())
454 .unwrap();
455 let verified = store.verify(&raw).unwrap();
456 assert_eq!(verified.identity, Some(identity));
457 assert!(verified.has_scope("authorize"));
458 assert!(!verified.has_scope("metrics:read"));
459 assert!(created.has_scope("authorize"));
460 }
461
462 #[test]
463 fn identity_rejects_unknown_subject_type_and_empty_fields() {
464 assert!(ApiKeyIdentity::new("Service", "svc", None).is_err());
465 assert!(ApiKeyIdentity::new("User", " ", None).is_err());
466 assert!(ApiKeyIdentity::new("Agent", "agent", Some(" ".into())).is_err());
467 }
468
469 #[test]
470 fn key_creation_rejects_invalid_prefix_and_expiry_overflow() {
471 let _guard = api_key_test_lock().lock();
472 let store = ApiKeyStore::new();
473 assert!(store.create("", vec![], None).is_err());
474 assert!(store.create(" ag ", vec![], None).is_err());
475 assert!(store.create("bad:prefix", vec![], None).is_err());
476 assert!(store.create("ag", vec!["bad scope".into()], None).is_err());
477 assert!(store
478 .create("ag", vec![], Some(Duration::from_secs(u64::MAX)))
479 .is_err());
480 }
481
482 #[test]
483 fn legacy_api_key_json_remains_readable_but_unbound() {
484 let legacy = serde_json::json!({
485 "id": "legacy",
486 "prefix": "ag",
487 "secret_hash": "hash",
488 "scopes": [],
489 "created_at": 0,
490 "expires_at": null,
491 "last_used_at": null,
492 "revoked_at": null
493 });
494 let key: ApiKey = serde_json::from_value(legacy).unwrap();
495 assert!(key.identity.is_none());
496 }
497
498 #[test]
499 fn save_to_file_replaces_complete_store_with_restrictive_permissions() {
500 let _guard = api_key_test_lock().lock();
501 let dir = tempfile::tempdir().unwrap();
502 let path = dir.path().join("keys.json");
503 let store = ApiKeyStore::new();
504 store.save_to_file(&path).unwrap();
505 let first = std::fs::read(&path).unwrap();
506 store
507 .create_bound(
508 "ag",
509 vec!["authorize".into()],
510 None,
511 ApiKeyIdentity::new("User", "alice", None).unwrap(),
512 )
513 .unwrap();
514 store.save_to_file(&path).unwrap();
515 let second = std::fs::read(&path).unwrap();
516 assert_ne!(first, second);
517 assert_eq!(ApiKeyStore::load_from_file(&path).unwrap().list().len(), 1);
518 #[cfg(unix)]
519 {
520 use std::os::unix::fs::PermissionsExt;
521 assert_eq!(
522 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
523 0o600
524 );
525 }
526 assert_eq!(
527 std::fs::read_dir(dir.path()).unwrap().count(),
528 1,
529 "temporary files should be removed after atomic replacement"
530 );
531 }
532
533 #[test]
534 fn reload_preserves_last_good_keys_when_replacement_snapshot_is_invalid() {
535 let _guard = api_key_test_lock().lock();
536 let dir = tempfile::tempdir().unwrap();
537 let path = dir.path().join("keys.json");
538 let admin = ApiKeyStore::new();
539 let (key, raw) = admin
540 .create_bound(
541 "ag",
542 vec!["authorize".into()],
543 None,
544 ApiKeyIdentity::new("User", "alice", None).unwrap(),
545 )
546 .unwrap();
547 admin.save_to_file(&path).unwrap();
548 let live = ApiKeyStore::load_from_file(&path).unwrap();
549 live.verify(&raw).unwrap();
550
551 std::fs::write(&path, b"not-json").unwrap();
552 assert!(live.reload_from_file(&path).is_err());
553 live.verify(&raw)
554 .expect("invalid snapshots retain the last known-good key set");
555
556 let mut malformed = admin.list();
557 malformed[0].secret_hash = "not-a-phc-hash".into();
558 std::fs::write(&path, serde_json::to_vec(&malformed).unwrap()).unwrap();
559 assert!(live.reload_from_file(&path).is_err());
560 live.verify(&raw)
561 .expect("malformed hash snapshots retain the last known-good key set");
562
563 let mut unbounded_cost = admin.list();
564 unbounded_cost[0].secret_hash = key.secret_hash.replace("m=65536", "m=65537");
565 assert_ne!(unbounded_cost[0].secret_hash, key.secret_hash);
566 std::fs::write(&path, serde_json::to_vec(&unbounded_cost).unwrap()).unwrap();
567 assert!(live.reload_from_file(&path).is_err());
568 live.verify(&raw)
569 .expect("unbounded hash-cost snapshots retain the last known-good key set");
570
571 let mut duplicate = admin.list();
572 duplicate.push(duplicate[0].clone());
573 std::fs::write(&path, serde_json::to_vec(&duplicate).unwrap()).unwrap();
574 assert!(live.reload_from_file(&path).is_err());
575 live.verify(&raw)
576 .expect("duplicate ids must not overwrite the active key set");
577
578 admin.revoke(&key.id).unwrap();
579 admin.save_to_file(&path).unwrap();
580 live.reload_from_file(&path).unwrap();
581 assert!(matches!(live.verify(&raw), Err(AuthError::ApiKeyRevoked)));
582 }
583
584 #[test]
585 fn wrong_secret_rejected() {
586 let _guard = api_key_test_lock().lock();
587 let s = ApiKeyStore::new();
588 let (_, mut raw) = s.create("ag", vec![], None).unwrap();
589 let last = raw.pop().unwrap();
591 let replacement = if last == 'A' { 'B' } else { 'A' };
592 let bad = format!("{}{}", raw, replacement);
593 let res = s.verify(&bad);
594 assert!(matches!(res, Err(AuthError::ApiKeyInvalid)));
595 }
596
597 #[test]
598 fn revoked_key_rejected() {
599 let _guard = api_key_test_lock().lock();
600 let s = ApiKeyStore::new();
601 let (key, raw) = s.create("ag", vec![], None).unwrap();
602 s.revoke(&key.id).unwrap();
603 let res = s.verify(&raw);
604 assert!(matches!(res, Err(AuthError::ApiKeyRevoked)));
605 }
606
607 #[test]
608 fn expired_key_rejected() {
609 let _guard = api_key_test_lock().lock();
610 let s = ApiKeyStore::new();
611 let (key, raw) = s.create("ag", vec![], None).unwrap();
612 s.keys.write().get_mut(&key.id).unwrap().expires_at = Some(chrono::Utc::now().timestamp());
613 assert!(matches!(s.verify(&raw), Err(AuthError::ApiKeyExpired)));
614 }
615}