1use std::collections::HashMap;
28use std::sync::Mutex;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ApiKey {
35 pub id: String,
39 pub user_id: String,
42 pub name: String,
44 pub prefix: String,
50 pub secret_hash: String,
62 pub scopes: Option<String>,
65 pub expires_at: Option<u64>,
68 pub last_used_at: Option<u64>,
71 pub created_at: u64,
72}
73
74pub trait ApiKeyBackend: Send + Sync {
77 fn put(&self, key: &ApiKey);
78 fn get(&self, id: &str) -> Option<ApiKey>;
79 fn delete(&self, id: &str) -> bool;
80 fn list_for_user(&self, user_id: &str) -> Vec<ApiKey>;
82 fn touch(&self, id: &str, now: u64);
87}
88
89pub struct InMemoryApiKeyBackend {
90 keys: Mutex<HashMap<String, ApiKey>>,
91}
92
93impl InMemoryApiKeyBackend {
94 pub fn new() -> Self {
95 Self {
96 keys: Mutex::new(HashMap::new()),
97 }
98 }
99}
100
101impl Default for InMemoryApiKeyBackend {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107impl ApiKeyBackend for InMemoryApiKeyBackend {
108 fn put(&self, key: &ApiKey) {
109 self.keys
110 .lock()
111 .unwrap()
112 .insert(key.id.clone(), key.clone());
113 }
114 fn get(&self, id: &str) -> Option<ApiKey> {
115 self.keys.lock().unwrap().get(id).cloned()
116 }
117 fn delete(&self, id: &str) -> bool {
118 self.keys.lock().unwrap().remove(id).is_some()
119 }
120 fn list_for_user(&self, user_id: &str) -> Vec<ApiKey> {
121 self.keys
122 .lock()
123 .unwrap()
124 .values()
125 .filter(|k| k.user_id == user_id)
126 .cloned()
127 .collect()
128 }
129 fn touch(&self, id: &str, now: u64) {
130 if let Some(k) = self.keys.lock().unwrap().get_mut(id) {
131 k.last_used_at = Some(now);
132 }
133 }
134}
135
136pub struct ApiKeyStore {
137 backend: Box<dyn ApiKeyBackend>,
138}
139
140impl Default for ApiKeyStore {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146#[derive(Debug, Clone)]
149pub enum ApiKeyVerifyError {
150 Malformed,
152 NotFound,
154 BadSecret,
156 Expired,
158}
159
160impl std::fmt::Display for ApiKeyVerifyError {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Self::Malformed => f.write_str("API key is malformed"),
164 Self::NotFound => f.write_str("API key not found"),
165 Self::BadSecret => f.write_str("API key secret mismatch"),
166 Self::Expired => f.write_str("API key has expired"),
167 }
168 }
169}
170
171impl ApiKeyStore {
172 pub fn new() -> Self {
173 Self::with_backend(Box::new(InMemoryApiKeyBackend::new()))
174 }
175 pub fn with_backend(backend: Box<dyn ApiKeyBackend>) -> Self {
176 Self { backend }
177 }
178
179 pub fn create(
190 &self,
191 user_id: String,
192 name: String,
193 scopes: Option<String>,
194 expires_at: Option<u64>,
195 ) -> (String, ApiKey) {
196 let id = format!("key_{}", random_token(24));
197 let secret = random_token(32);
198 let plaintext = format!("pk.{id}.{secret}");
199 let prefix: String = plaintext.chars().take(16).collect();
200 let key = ApiKey {
201 id: id.clone(),
202 user_id,
203 name,
204 prefix,
205 secret_hash: hash_secret(&secret),
206 scopes,
207 expires_at,
208 last_used_at: None,
209 created_at: now_secs(),
210 };
211 self.backend.put(&key);
212 (plaintext, key)
213 }
214
215 pub fn verify(&self, token: &str) -> Result<ApiKey, ApiKeyVerifyError> {
222 let (id, secret) = parse_token(token).ok_or(ApiKeyVerifyError::Malformed)?;
223 let key = self.backend.get(&id).ok_or(ApiKeyVerifyError::NotFound)?;
224 if let Some(exp) = key.expires_at {
225 if exp <= now_secs() {
226 return Err(ApiKeyVerifyError::Expired);
227 }
228 }
229 let expected = hash_secret(&secret);
230 if !crate::constant_time_eq(expected.as_bytes(), key.secret_hash.as_bytes()) {
231 return Err(ApiKeyVerifyError::BadSecret);
232 }
233 let now = now_secs();
236 if key.last_used_at.map(|t| now - t > 60).unwrap_or(true) {
237 self.backend.touch(&key.id, now);
238 }
239 Ok(key)
240 }
241
242 pub fn revoke(&self, id: &str) -> bool {
243 self.backend.delete(id)
244 }
245
246 pub fn list_for_user(&self, user_id: &str) -> Vec<ApiKey> {
247 self.backend.list_for_user(user_id)
248 }
249}
250
251fn parse_token(token: &str) -> Option<(String, String)> {
261 let rest = token.strip_prefix("pk.")?;
262 let mut parts = rest.split('.');
264 let id_part = parts.next()?;
265 let secret = parts.next()?;
266 if parts.next().is_some() {
267 return None;
268 }
269 if !id_part.starts_with("key_") {
270 return None;
271 }
272 let id_body = &id_part[4..]; if id_body.len() != 32 || secret.len() != 43 {
276 return None;
277 }
278 if !is_base64url(id_body) || !is_base64url(secret) {
279 return None;
280 }
281 Some((id_part.to_string(), secret.to_string()))
282}
283
284fn is_base64url(s: &str) -> bool {
286 s.bytes().all(|b| {
287 matches!(b,
288 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_')
289 })
290}
291
292fn random_token(n_bytes: usize) -> String {
293 use rand::RngCore;
294 let mut bytes = vec![0u8; n_bytes];
295 rand::thread_rng().fill_bytes(&mut bytes);
296 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
297 URL_SAFE_NO_PAD.encode(bytes)
298}
299
300fn hash_secret(secret: &str) -> String {
306 use hmac::{Hmac, Mac};
307 use sha2::Sha256;
308 type HmacSha256 = Hmac<Sha256>;
309 let pepper = std::env::var("PYLON_API_KEY_PEPPER")
312 .unwrap_or_else(|_| "pylon-dev-api-key-pepper-not-for-production".into());
313 let mut mac =
314 HmacSha256::new_from_slice(pepper.as_bytes()).expect("HMAC accepts any key length");
315 mac.update(secret.as_bytes());
316 let out = mac.finalize().into_bytes();
317 use std::fmt::Write;
318 let mut s = String::with_capacity(64);
319 for b in out {
320 let _ = write!(s, "{b:02x}");
321 }
322 s
323}
324
325fn now_secs() -> u64 {
326 use std::time::{SystemTime, UNIX_EPOCH};
327 SystemTime::now()
328 .duration_since(UNIX_EPOCH)
329 .unwrap_or_default()
330 .as_secs()
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn create_and_verify_roundtrip() {
339 let store = ApiKeyStore::new();
340 let (plaintext, key) = store.create(
341 "user_1".into(),
342 "test".into(),
343 Some("read,write".into()),
344 None,
345 );
346 assert!(plaintext.starts_with("pk.key_"));
347 let verified = store.verify(&plaintext).expect("verify");
348 assert_eq!(verified.id, key.id);
349 assert_eq!(verified.user_id, "user_1");
350 assert_eq!(verified.scopes.as_deref(), Some("read,write"));
351 }
352
353 #[test]
354 fn malformed_token_rejected() {
355 let store = ApiKeyStore::new();
356 let err = store.verify("not_a_real_key").unwrap_err();
357 assert!(matches!(err, ApiKeyVerifyError::Malformed));
358 }
359
360 #[test]
361 fn unknown_id_returns_not_found() {
362 let store = ApiKeyStore::new();
363 let token = format!("pk.key_{}.{}", "z".repeat(32), "y".repeat(43));
365 let err = store.verify(&token).unwrap_err();
366 assert!(matches!(err, ApiKeyVerifyError::NotFound), "got: {err}");
367 }
368
369 #[test]
370 fn wrong_secret_rejected() {
371 let store = ApiKeyStore::new();
372 let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
373 let mut bad = plaintext;
374 bad.pop();
375 bad.push('X');
376 let err = store.verify(&bad).unwrap_err();
377 assert!(matches!(err, ApiKeyVerifyError::BadSecret), "got: {err}");
378 let _ = key.id;
381 }
382
383 #[test]
384 fn expired_key_rejected() {
385 let store = ApiKeyStore::new();
386 let (plaintext, _) = store.create("u".into(), "n".into(), None, Some(now_secs() - 1));
387 let err = store.verify(&plaintext).unwrap_err();
388 assert!(matches!(err, ApiKeyVerifyError::Expired));
389 }
390
391 #[test]
392 fn revoke_removes_key() {
393 let store = ApiKeyStore::new();
394 let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
395 assert!(store.revoke(&key.id));
396 let err = store.verify(&plaintext).unwrap_err();
397 assert!(matches!(err, ApiKeyVerifyError::NotFound));
398 }
399
400 #[test]
401 fn touch_updates_last_used_at() {
402 let store = ApiKeyStore::new();
403 let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
404 assert!(key.last_used_at.is_none());
405 let _ = store.verify(&plaintext);
406 let after = store.list_for_user("u")[0].clone();
407 assert!(after.last_used_at.is_some(), "touch should refresh");
408 }
409
410 #[test]
411 fn list_for_user_only_returns_owned() {
412 let store = ApiKeyStore::new();
413 let _ = store.create("alice".into(), "k1".into(), None, None);
414 let _ = store.create("alice".into(), "k2".into(), None, None);
415 let _ = store.create("bob".into(), "k3".into(), None, None);
416 assert_eq!(store.list_for_user("alice").len(), 2);
417 assert_eq!(store.list_for_user("bob").len(), 1);
418 }
419
420 #[test]
421 fn parse_token_accepts_well_formed() {
422 let id_body = "a".repeat(32);
425 let secret = "b".repeat(43);
426 let token = format!("pk.key_{id_body}.{secret}");
427 let parsed = parse_token(&token).unwrap();
428 assert_eq!(parsed.0, format!("key_{id_body}"));
429 assert_eq!(parsed.1, secret);
430 }
431
432 #[test]
433 fn parse_token_rejects_malformed() {
434 assert!(parse_token("pk.key_abc.").is_none());
436 assert!(parse_token("pk.key_abc").is_none());
437 assert!(parse_token(&format!("pk.abc.{}", "b".repeat(43))).is_none());
439 assert!(parse_token(&format!("xy.key_{}.{}", "a".repeat(32), "b".repeat(43))).is_none());
441 assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(31), "b".repeat(43))).is_none());
443 assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(32), "b".repeat(42))).is_none());
445 assert!(parse_token(&format!("pk.key_{}.{}", "@".repeat(32), "b".repeat(43))).is_none());
447 assert!(parse_token(&format!(
449 "pk.key_{}.{}.junk",
450 "a".repeat(32),
451 "b".repeat(43)
452 ))
453 .is_none());
454 }
455
456 #[test]
461 fn random_keys_with_underscores_round_trip() {
462 let store = ApiKeyStore::new();
463 for _ in 0..20 {
465 let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
466 let verified = store
467 .verify(&plaintext)
468 .expect("base64url body must verify");
469 assert_eq!(verified.id, key.id);
470 }
471 }
472}