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.lock().unwrap().insert(key.id.clone(), key.clone());
110 }
111 fn get(&self, id: &str) -> Option<ApiKey> {
112 self.keys.lock().unwrap().get(id).cloned()
113 }
114 fn delete(&self, id: &str) -> bool {
115 self.keys.lock().unwrap().remove(id).is_some()
116 }
117 fn list_for_user(&self, user_id: &str) -> Vec<ApiKey> {
118 self.keys
119 .lock()
120 .unwrap()
121 .values()
122 .filter(|k| k.user_id == user_id)
123 .cloned()
124 .collect()
125 }
126 fn touch(&self, id: &str, now: u64) {
127 if let Some(k) = self.keys.lock().unwrap().get_mut(id) {
128 k.last_used_at = Some(now);
129 }
130 }
131}
132
133pub struct ApiKeyStore {
134 backend: Box<dyn ApiKeyBackend>,
135}
136
137impl Default for ApiKeyStore {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143#[derive(Debug, Clone)]
146pub enum ApiKeyVerifyError {
147 Malformed,
149 NotFound,
151 BadSecret,
153 Expired,
155}
156
157impl std::fmt::Display for ApiKeyVerifyError {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 match self {
160 Self::Malformed => f.write_str("API key is malformed"),
161 Self::NotFound => f.write_str("API key not found"),
162 Self::BadSecret => f.write_str("API key secret mismatch"),
163 Self::Expired => f.write_str("API key has expired"),
164 }
165 }
166}
167
168impl ApiKeyStore {
169 pub fn new() -> Self {
170 Self::with_backend(Box::new(InMemoryApiKeyBackend::new()))
171 }
172 pub fn with_backend(backend: Box<dyn ApiKeyBackend>) -> Self {
173 Self { backend }
174 }
175
176 pub fn create(
187 &self,
188 user_id: String,
189 name: String,
190 scopes: Option<String>,
191 expires_at: Option<u64>,
192 ) -> (String, ApiKey) {
193 let id = format!("key_{}", random_token(24));
194 let secret = random_token(32);
195 let plaintext = format!("pk.{id}.{secret}");
196 let prefix: String = plaintext.chars().take(16).collect();
197 let key = ApiKey {
198 id: id.clone(),
199 user_id,
200 name,
201 prefix,
202 secret_hash: hash_secret(&secret),
203 scopes,
204 expires_at,
205 last_used_at: None,
206 created_at: now_secs(),
207 };
208 self.backend.put(&key);
209 (plaintext, key)
210 }
211
212 pub fn verify(&self, token: &str) -> Result<ApiKey, ApiKeyVerifyError> {
219 let (id, secret) = parse_token(token).ok_or(ApiKeyVerifyError::Malformed)?;
220 let key = self.backend.get(&id).ok_or(ApiKeyVerifyError::NotFound)?;
221 if let Some(exp) = key.expires_at {
222 if exp <= now_secs() {
223 return Err(ApiKeyVerifyError::Expired);
224 }
225 }
226 let expected = hash_secret(&secret);
227 if !crate::constant_time_eq(expected.as_bytes(), key.secret_hash.as_bytes()) {
228 return Err(ApiKeyVerifyError::BadSecret);
229 }
230 let now = now_secs();
233 if key.last_used_at.map(|t| now - t > 60).unwrap_or(true) {
234 self.backend.touch(&key.id, now);
235 }
236 Ok(key)
237 }
238
239 pub fn revoke(&self, id: &str) -> bool {
240 self.backend.delete(id)
241 }
242
243 pub fn list_for_user(&self, user_id: &str) -> Vec<ApiKey> {
244 self.backend.list_for_user(user_id)
245 }
246}
247
248fn parse_token(token: &str) -> Option<(String, String)> {
258 let rest = token.strip_prefix("pk.")?;
259 let mut parts = rest.split('.');
261 let id_part = parts.next()?;
262 let secret = parts.next()?;
263 if parts.next().is_some() {
264 return None;
265 }
266 if !id_part.starts_with("key_") {
267 return None;
268 }
269 let id_body = &id_part[4..]; if id_body.len() != 32 || secret.len() != 43 {
273 return None;
274 }
275 if !is_base64url(id_body) || !is_base64url(secret) {
276 return None;
277 }
278 Some((id_part.to_string(), secret.to_string()))
279}
280
281fn is_base64url(s: &str) -> bool {
283 s.bytes().all(|b| {
284 matches!(b,
285 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_')
286 })
287}
288
289fn random_token(n_bytes: usize) -> String {
290 use rand::RngCore;
291 let mut bytes = vec![0u8; n_bytes];
292 rand::thread_rng().fill_bytes(&mut bytes);
293 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
294 URL_SAFE_NO_PAD.encode(bytes)
295}
296
297fn hash_secret(secret: &str) -> String {
303 use hmac::{Hmac, Mac};
304 use sha2::Sha256;
305 type HmacSha256 = Hmac<Sha256>;
306 let pepper = std::env::var("PYLON_API_KEY_PEPPER")
309 .unwrap_or_else(|_| "pylon-dev-api-key-pepper-not-for-production".into());
310 let mut mac = HmacSha256::new_from_slice(pepper.as_bytes())
311 .expect("HMAC accepts any key length");
312 mac.update(secret.as_bytes());
313 let out = mac.finalize().into_bytes();
314 use std::fmt::Write;
315 let mut s = String::with_capacity(64);
316 for b in out {
317 let _ = write!(s, "{b:02x}");
318 }
319 s
320}
321
322fn now_secs() -> u64 {
323 use std::time::{SystemTime, UNIX_EPOCH};
324 SystemTime::now()
325 .duration_since(UNIX_EPOCH)
326 .unwrap_or_default()
327 .as_secs()
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn create_and_verify_roundtrip() {
336 let store = ApiKeyStore::new();
337 let (plaintext, key) =
338 store.create("user_1".into(), "test".into(), Some("read,write".into()), None);
339 assert!(plaintext.starts_with("pk.key_"));
340 let verified = store.verify(&plaintext).expect("verify");
341 assert_eq!(verified.id, key.id);
342 assert_eq!(verified.user_id, "user_1");
343 assert_eq!(verified.scopes.as_deref(), Some("read,write"));
344 }
345
346 #[test]
347 fn malformed_token_rejected() {
348 let store = ApiKeyStore::new();
349 let err = store.verify("not_a_real_key").unwrap_err();
350 assert!(matches!(err, ApiKeyVerifyError::Malformed));
351 }
352
353 #[test]
354 fn unknown_id_returns_not_found() {
355 let store = ApiKeyStore::new();
356 let token = format!("pk.key_{}.{}", "z".repeat(32), "y".repeat(43));
358 let err = store.verify(&token).unwrap_err();
359 assert!(matches!(err, ApiKeyVerifyError::NotFound), "got: {err}");
360 }
361
362 #[test]
363 fn wrong_secret_rejected() {
364 let store = ApiKeyStore::new();
365 let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
366 let mut bad = plaintext;
367 bad.pop();
368 bad.push('X');
369 let err = store.verify(&bad).unwrap_err();
370 assert!(matches!(err, ApiKeyVerifyError::BadSecret), "got: {err}");
371 let _ = key.id;
374 }
375
376 #[test]
377 fn expired_key_rejected() {
378 let store = ApiKeyStore::new();
379 let (plaintext, _) =
380 store.create("u".into(), "n".into(), None, Some(now_secs() - 1));
381 let err = store.verify(&plaintext).unwrap_err();
382 assert!(matches!(err, ApiKeyVerifyError::Expired));
383 }
384
385 #[test]
386 fn revoke_removes_key() {
387 let store = ApiKeyStore::new();
388 let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
389 assert!(store.revoke(&key.id));
390 let err = store.verify(&plaintext).unwrap_err();
391 assert!(matches!(err, ApiKeyVerifyError::NotFound));
392 }
393
394 #[test]
395 fn touch_updates_last_used_at() {
396 let store = ApiKeyStore::new();
397 let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
398 assert!(key.last_used_at.is_none());
399 let _ = store.verify(&plaintext);
400 let after = store.list_for_user("u")[0].clone();
401 assert!(after.last_used_at.is_some(), "touch should refresh");
402 }
403
404 #[test]
405 fn list_for_user_only_returns_owned() {
406 let store = ApiKeyStore::new();
407 let _ = store.create("alice".into(), "k1".into(), None, None);
408 let _ = store.create("alice".into(), "k2".into(), None, None);
409 let _ = store.create("bob".into(), "k3".into(), None, None);
410 assert_eq!(store.list_for_user("alice").len(), 2);
411 assert_eq!(store.list_for_user("bob").len(), 1);
412 }
413
414 #[test]
415 fn parse_token_accepts_well_formed() {
416 let id_body = "a".repeat(32);
419 let secret = "b".repeat(43);
420 let token = format!("pk.key_{id_body}.{secret}");
421 let parsed = parse_token(&token).unwrap();
422 assert_eq!(parsed.0, format!("key_{id_body}"));
423 assert_eq!(parsed.1, secret);
424 }
425
426 #[test]
427 fn parse_token_rejects_malformed() {
428 assert!(parse_token("pk.key_abc.").is_none());
430 assert!(parse_token("pk.key_abc").is_none());
431 assert!(parse_token(&format!("pk.abc.{}", "b".repeat(43))).is_none());
433 assert!(parse_token(&format!("xy.key_{}.{}", "a".repeat(32), "b".repeat(43))).is_none());
435 assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(31), "b".repeat(43))).is_none());
437 assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(32), "b".repeat(42))).is_none());
439 assert!(parse_token(&format!("pk.key_{}.{}", "@".repeat(32), "b".repeat(43))).is_none());
441 assert!(parse_token(&format!("pk.key_{}.{}.junk", "a".repeat(32), "b".repeat(43))).is_none());
443 }
444
445 #[test]
450 fn random_keys_with_underscores_round_trip() {
451 let store = ApiKeyStore::new();
452 for _ in 0..20 {
454 let (plaintext, key) = store.create("u".into(), "n".into(), None, None);
455 let verified = store.verify(&plaintext).expect("base64url body must verify");
456 assert_eq!(verified.id, key.id);
457 }
458 }
459}