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.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/// Verification result — carries the matched key so the caller can
144/// inspect scopes / expiry without a second backend round-trip.
145#[derive(Debug, Clone)]
146pub enum ApiKeyVerifyError {
147    /// Token format is wrong (no `pk_` prefix or wrong length).
148    Malformed,
149    /// Token format is OK but the embedded id isn't in the store.
150    NotFound,
151    /// Token + id matched a stored key but the secret didn't verify.
152    BadSecret,
153    /// `expires_at` has passed.
154    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    /// Mint a new API key. Returns `(plaintext, ApiKey)` — the
177    /// plaintext MUST be shown to the user exactly once and never
178    /// stored anywhere on the server. The `ApiKey` is what's
179    /// persisted (with `secret_hash` not the secret).
180    ///
181    /// Wire format: `pk.<id>.<secret>` — the id is embedded so
182    /// verification is one DB lookup, not a table scan. Hash-only
183    /// schemes that store no plaintext id make verification O(N).
184    /// `.` separator (not `_`) so it survives the URL-safe base64
185    /// alphabet that base64url uses for both id and secret bodies.
186    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    /// Verify a plaintext token. Touches `last_used_at` on success
213    /// so the management UI can show "last used 5m ago".
214    ///
215    /// `touch` is debounced to once-per-minute per key to avoid a
216    /// write storm on hot keys (one DB write per request was a real
217    /// contention source under load).
218    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        // Debounced last_used_at update — no point persisting a
231        // touch within 60s of the previous one.
232        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
248/// Split `pk.<id>.<secret>` into `(id, secret)`. Returns `None` if
249/// the format doesn't match exactly. `.` separator survives the
250/// base64url alphabet — `_` and `-` are valid base64url chars and
251/// would create false split points.
252///
253/// Tightened (codex Wave-2 P3):
254///   - rejects extra `.` segments (`pk.id.secret.junk`)
255///   - rejects non-base64url chars in id or secret
256///   - rejects mismatched lengths (id is `key_` + 32 chars, secret is 43 chars)
257fn parse_token(token: &str) -> Option<(String, String)> {
258    let rest = token.strip_prefix("pk.")?;
259    // Exactly two `.`-separated segments after the `pk.` header.
260    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..]; // strip "key_"
270    // 24 random bytes → base64url-no-pad → 32 chars.
271    // 32 random bytes → base64url-no-pad → 43 chars.
272    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
281/// Base64url alphabet check — `[A-Za-z0-9_-]` per RFC 4648 §5.
282fn 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
297/// HMAC-SHA256 the secret with a server-side pepper. Returns hex.
298/// The pepper is read from `PYLON_API_KEY_PEPPER` (set this in
299/// production — apps that don't risk the pepper being a known
300/// constant). For dev convenience an unset pepper yields a fixed
301/// dev value so testing works without env setup.
302fn hash_secret(secret: &str) -> String {
303    use hmac::{Hmac, Mac};
304    use sha2::Sha256;
305    type HmacSha256 = Hmac<Sha256>;
306    // OnceLock would be nicer but std env::var per call is fine:
307    // we already trade-off env reads vs cache complexity elsewhere.
308    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        // Well-formed token shape but unknown id → NotFound, not Malformed.
357        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        // The id should still resolve, so the error path is BadSecret
372        // not NotFound — confirms we don't accidentally truncate the id.
373        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        // Real-shape token: id is "key_" + 32 base64url chars,
417        // secret is 43 base64url chars.
418        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        // empty parts
429        assert!(parse_token("pk.key_abc.").is_none());
430        assert!(parse_token("pk.key_abc").is_none());
431        // missing key_ prefix
432        assert!(parse_token(&format!("pk.abc.{}", "b".repeat(43))).is_none());
433        // wrong outer prefix
434        assert!(parse_token(&format!("xy.key_{}.{}", "a".repeat(32), "b".repeat(43))).is_none());
435        // wrong id length
436        assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(31), "b".repeat(43))).is_none());
437        // wrong secret length
438        assert!(parse_token(&format!("pk.key_{}.{}", "a".repeat(32), "b".repeat(42))).is_none());
439        // non-base64url chars
440        assert!(parse_token(&format!("pk.key_{}.{}", "@".repeat(32), "b".repeat(43))).is_none());
441        // extra dots / segments (codex P3)
442        assert!(parse_token(&format!("pk.key_{}.{}.junk", "a".repeat(32), "b".repeat(43))).is_none());
443    }
444
445    /// Regression: id and secret are base64url which contains `_` and
446    /// `-`. Previous wire format used `_` as separator, which split
447    /// the id at the wrong place when it contained an underscore.
448    /// `.` separator avoids that class of bug.
449    #[test]
450    fn random_keys_with_underscores_round_trip() {
451        let store = ApiKeyStore::new();
452        // Run a handful of times to defeat lucky-RNG flakes.
453        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}