Skip to main content

pylon_auth/
api_key.rs

1//! API keys — long-lived bearer tokens for service-to-service or
2//! mobile clients that don't fit the cookie-session model.
3//!
4//! Wire format: `pk_<32-char-base64url>` so they're trivially
5//! distinguishable from session tokens (`pylon_…`) at a glance and
6//! in log greps. Verification stores the **hash** of the secret —
7//! the plaintext is shown to the user exactly once at create time
8//! and never again, same pattern as Stripe / GitHub PATs.
9//!
10//! Key trust model:
11//! - Each key belongs to one user (`user_id`).
12//! - Optional `name` for the user to identify it ("CI", "iOS app").
13//! - Optional `scopes` — comma-separated strings the application
14//!   layer interprets. Pylon doesn't enforce them; the host app's
15//!   policies do.
16//! - Optional `expires_at` — when set, requests with the key are
17//!   rejected after this Unix timestamp. `None` means no expiry
18//!   (set + forget for trusted CI machines).
19//! - Optional `last_used_at` — refreshed on every successful auth
20//!   so the user can prune stale keys from a "remove unused for
21//!   90 days" sweep.
22//!
23//! Storage is pluggable via [`ApiKeyBackend`] — the runtime swaps
24//! in SQLite/Postgres backends behind the scenes; the in-memory
25//! default is fine for tests + ephemeral dev servers.
26
27use std::collections::HashMap;
28use std::sync::Mutex;
29
30/// One stored API key. The `secret_hash` is what's persisted; the
31/// plaintext secret is returned to the caller exactly once at create
32/// time (see [`ApiKeyStore::create`]).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ApiKey {
35    /// Stable identifier — what the dashboard / management UI lists.
36    /// Format: `key_<24-char-base64url>`. Distinct from `prefix` so a
37    /// user can revoke by id without seeing the secret prefix.
38    pub id: String,
39    /// User who owns this key. Auth context resolves to this user_id
40    /// when the key authenticates.
41    pub user_id: String,
42    /// Friendly name set by the owner. Free-form; UI-only.
43    pub name: String,
44    /// First 16 chars of the FULL plaintext token (`pk.key_<8 id chars>`).
45    /// Safe to display in management UIs since this prefix encodes
46    /// only the key id, not any of the secret material — the secret
47    /// starts AFTER the second `.` separator. Lets the user
48    /// distinguish keys by sight without ever exposing the secret.
49    pub prefix: String,
50    /// HMAC-SHA256 hash of the secret using a server-side pepper
51    /// (`PYLON_API_KEY_PEPPER`, or a fixed dev pepper when unset).
52    /// Verified at request time via constant-time compare.
53    ///
54    /// **Why HMAC-SHA256, not Argon2?** Argon2 exists to slow brute
55    /// force of LOW-entropy passwords. API key secrets are 32 random
56    /// bytes (256 bits) — brute force is computationally infeasible
57    /// regardless of hash speed. Using Argon2 here would add ~50ms
58    /// of latency per request for zero security benefit. SHA-256
59    /// HMAC at ~1µs gives the same effective security plus 50000×
60    /// throughput.
61    pub secret_hash: String,
62    /// Comma-separated scope strings. Application-defined; pylon
63    /// stores opaquely.
64    pub scopes: Option<String>,
65    /// Unix timestamp at which this key stops being valid. None for
66    /// no-expiry keys.
67    pub expires_at: Option<u64>,
68    /// Unix timestamp of the most recent successful auth — refreshed
69    /// on every verify. None until the first use.
70    pub last_used_at: Option<u64>,
71    pub created_at: u64,
72}
73
74/// Storage backend for API keys. Same pluggable pattern as sessions
75/// + magic codes — in-memory default, runtime injects SQLite/Postgres.
76pub 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    /// All keys for a given user, used by management endpoints.
81    fn list_for_user(&self, user_id: &str) -> Vec<ApiKey>;
82    /// Update `last_used_at`. Called on every successful auth — must
83    /// be cheap. Implementations are free to debounce (write at most
84    /// once per minute, etc.) but the in-memory default writes
85    /// straight through.
86    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/// Verification result — carries the matched key so the caller can
147/// inspect scopes / expiry without a second backend round-trip.
148#[derive(Debug, Clone)]
149pub enum ApiKeyVerifyError {
150    /// Token format is wrong (no `pk_` prefix or wrong length).
151    Malformed,
152    /// Token format is OK but the embedded id isn't in the store.
153    NotFound,
154    /// Token + id matched a stored key but the secret didn't verify.
155    BadSecret,
156    /// `expires_at` has passed.
157    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    /// Mint a new API key. Returns `(plaintext, ApiKey)` — the
180    /// plaintext MUST be shown to the user exactly once and never
181    /// stored anywhere on the server. The `ApiKey` is what's
182    /// persisted (with `secret_hash` not the secret).
183    ///
184    /// Wire format: `pk.<id>.<secret>` — the id is embedded so
185    /// verification is one DB lookup, not a table scan. Hash-only
186    /// schemes that store no plaintext id make verification O(N).
187    /// `.` separator (not `_`) so it survives the URL-safe base64
188    /// alphabet that base64url uses for both id and secret bodies.
189    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    /// Verify a plaintext token. Touches `last_used_at` on success
216    /// so the management UI can show "last used 5m ago".
217    ///
218    /// `touch` is debounced to once-per-minute per key to avoid a
219    /// write storm on hot keys (one DB write per request was a real
220    /// contention source under load).
221    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        // Debounced last_used_at update — no point persisting a
234        // touch within 60s of the previous one.
235        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
251/// Split `pk.<id>.<secret>` into `(id, secret)`. Returns `None` if
252/// the format doesn't match exactly. `.` separator survives the
253/// base64url alphabet — `_` and `-` are valid base64url chars and
254/// would create false split points.
255///
256/// Tightened (codex Wave-2 P3):
257///   - rejects extra `.` segments (`pk.id.secret.junk`)
258///   - rejects non-base64url chars in id or secret
259///   - rejects mismatched lengths (id is `key_` + 32 chars, secret is 43 chars)
260fn parse_token(token: &str) -> Option<(String, String)> {
261    let rest = token.strip_prefix("pk.")?;
262    // Exactly two `.`-separated segments after the `pk.` header.
263    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..]; // strip "key_"
273                                 // 24 random bytes → base64url-no-pad → 32 chars.
274                                 // 32 random bytes → base64url-no-pad → 43 chars.
275    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
284/// Base64url alphabet check — `[A-Za-z0-9_-]` per RFC 4648 §5.
285fn 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
300/// HMAC-SHA256 the secret with a server-side pepper. Returns hex.
301/// The pepper is read from `PYLON_API_KEY_PEPPER` (set this in
302/// production — apps that don't risk the pepper being a known
303/// constant). For dev convenience an unset pepper yields a fixed
304/// dev value so testing works without env setup.
305fn hash_secret(secret: &str) -> String {
306    use hmac::{Hmac, Mac};
307    use sha2::Sha256;
308    type HmacSha256 = Hmac<Sha256>;
309    // OnceLock would be nicer but std env::var per call is fine:
310    // we already trade-off env reads vs cache complexity elsewhere.
311    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        // Well-formed token shape but unknown id → NotFound, not Malformed.
364        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        // The id should still resolve, so the error path is BadSecret
379        // not NotFound — confirms we don't accidentally truncate the id.
380        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        // Real-shape token: id is "key_" + 32 base64url chars,
423        // secret is 43 base64url chars.
424        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        // empty parts
435        assert!(parse_token("pk.key_abc.").is_none());
436        assert!(parse_token("pk.key_abc").is_none());
437        // missing key_ prefix
438        assert!(parse_token(&format!("pk.abc.{}", "b".repeat(43))).is_none());
439        // wrong outer prefix
440        assert!(parse_token(&format!("xy.key_{}.{}", "a".repeat(32), "b".repeat(43))).is_none());
441        // wrong id length
442        assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(31), "b".repeat(43))).is_none());
443        // wrong secret length
444        assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(32), "b".repeat(42))).is_none());
445        // non-base64url chars
446        assert!(parse_token(&format!("pk.key_{}.{}", "@".repeat(32), "b".repeat(43))).is_none());
447        // extra dots / segments (codex P3)
448        assert!(parse_token(&format!(
449            "pk.key_{}.{}.junk",
450            "a".repeat(32),
451            "b".repeat(43)
452        ))
453        .is_none());
454    }
455
456    /// Regression: id and secret are base64url which contains `_` and
457    /// `-`. Previous wire format used `_` as separator, which split
458    /// the id at the wrong place when it contained an underscore.
459    /// `.` separator avoids that class of bug.
460    #[test]
461    fn random_keys_with_underscores_round_trip() {
462        let store = ApiKeyStore::new();
463        // Run a handful of times to defeat lucky-RNG flakes.
464        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}