Skip to main content

pylon_plugin/builtin/
api_keys.rs

1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use sha2::{Digest, Sha256};
5
6use crate::{Plugin, PluginError};
7use pylon_auth::AuthContext;
8
9/// An API key with scoped permissions.
10///
11/// The raw key is never stored. Only the hash is retained after creation.
12#[derive(Debug, Clone)]
13pub struct ApiKey {
14    pub key_hash: String,
15    pub name: String,
16    pub user_id: String,
17    pub scopes: Vec<String>,
18    pub created_at: String,
19}
20
21/// Result returned from key creation. Contains the raw key (shown only once)
22/// alongside the stored metadata.
23#[derive(Debug, Clone)]
24pub struct CreatedApiKey {
25    /// The raw API key. This is the only time the caller will see it.
26    pub raw_key: String,
27    pub name: String,
28    pub user_id: String,
29    pub scopes: Vec<String>,
30    pub created_at: String,
31}
32
33/// API Keys plugin. Allows issuing and revoking API keys with scoped permissions.
34///
35/// Keys are stored as SHA-256 hashes only. The raw key is returned exactly once
36/// at creation time and is never persisted.
37pub struct ApiKeysPlugin {
38    /// Map from `sha256(api_key)` -> `ApiKey` metadata.
39    keys: Mutex<HashMap<String, ApiKey>>,
40}
41
42fn hex_encode(bytes: &[u8]) -> String {
43    bytes.iter().map(|b| format!("{:02x}", b)).collect()
44}
45
46/// Produce a SHA-256 hash of an API key string.
47///
48/// Returns a lowercase hex-encoded 64-character string (256 bits).
49fn hash_key(key: &str) -> String {
50    let mut hasher = Sha256::new();
51    hasher.update(key.as_bytes());
52    let result = hasher.finalize();
53    hex_encode(&result)
54}
55
56/// Generate an API key with 192 bits of entropy from a CSPRNG.
57fn generate_key() -> String {
58    use rand::Rng;
59    let mut rng = rand::thread_rng();
60    let bytes: [u8; 24] = rng.gen();
61    format!("pylon_{}", hex_encode(&bytes))
62}
63
64impl ApiKeysPlugin {
65    pub fn new() -> Self {
66        Self {
67            keys: Mutex::new(HashMap::new()),
68        }
69    }
70
71    /// Create a new API key. Returns a [`CreatedApiKey`] containing the raw key.
72    /// The raw key is **not** stored; only its SHA-256 hash is retained.
73    pub fn create_key(&self, name: &str, user_id: &str, scopes: Vec<String>) -> CreatedApiKey {
74        let raw_key = generate_key();
75        let key_hash = hash_key(&raw_key);
76
77        let api_key = ApiKey {
78            key_hash: key_hash.clone(),
79            name: name.to_string(),
80            user_id: user_id.to_string(),
81            scopes: scopes.clone(),
82            created_at: now(),
83        };
84        self.keys.lock().unwrap().insert(key_hash, api_key);
85
86        CreatedApiKey {
87            raw_key,
88            name: name.to_string(),
89            user_id: user_id.to_string(),
90            scopes,
91            created_at: now(),
92        }
93    }
94
95    /// Resolve an API key to an auth context.
96    /// Hashes the provided key and performs an O(1) HashMap lookup.
97    ///
98    /// The returned `AuthContext` is DETACHED from this store — if the key
99    /// is later revoked, callers holding the context won't see the change.
100    /// This matters for middleware/session layers that cache the resolved
101    /// context across requests. Such callers should also call
102    /// [`is_active`] on every request or re-`resolve` to pick up
103    /// revocations.
104    pub fn resolve(&self, key: &str) -> Option<AuthContext> {
105        let h = hash_key(key);
106        let keys = self.keys.lock().unwrap();
107        keys.get(&h)
108            .map(|k| AuthContext::authenticated(k.user_id.clone()))
109    }
110
111    /// Returns true if the raw key still exists in the store. Use this to
112    /// validate a cached `AuthContext` against the current revocation state
113    /// before trusting it on a subsequent request.
114    pub fn is_active(&self, key: &str) -> bool {
115        let h = hash_key(key);
116        self.keys.lock().unwrap().contains_key(&h)
117    }
118
119    /// Check if an API key has a specific scope.
120    pub fn has_scope(&self, key: &str, scope: &str) -> bool {
121        let h = hash_key(key);
122        let keys = self.keys.lock().unwrap();
123        keys.get(&h)
124            .map(|k| k.scopes.is_empty() || k.scopes.iter().any(|s| s == scope || s == "*"))
125            .unwrap_or(false)
126    }
127
128    /// Revoke an API key. The caller must provide the raw key; it is hashed
129    /// internally to locate the stored entry.
130    pub fn revoke(&self, key: &str) -> bool {
131        let h = hash_key(key);
132        self.keys.lock().unwrap().remove(&h).is_some()
133    }
134
135    /// List all API keys for a user.
136    ///
137    /// Returns [`ApiKey`] entries which contain the hash but **not** the raw key.
138    pub fn list_keys(&self, user_id: &str) -> Vec<ApiKey> {
139        self.keys
140            .lock()
141            .unwrap()
142            .values()
143            .filter(|k| k.user_id == user_id)
144            .cloned()
145            .collect()
146    }
147}
148
149impl Plugin for ApiKeysPlugin {
150    fn name(&self) -> &str {
151        "api-keys"
152    }
153
154    fn on_request(
155        &self,
156        _method: &str,
157        _path: &str,
158        _auth: &AuthContext,
159    ) -> Result<(), PluginError> {
160        // API keys are resolved at the auth layer, not here.
161        // This hook could be used for scope checking per-route if needed.
162        Ok(())
163    }
164}
165
166fn now() -> String {
167    use std::time::{SystemTime, UNIX_EPOCH};
168    format!(
169        "{}Z",
170        SystemTime::now()
171            .duration_since(UNIX_EPOCH)
172            .unwrap_or_default()
173            .as_secs()
174    )
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn create_and_resolve() {
183        let plugin = ApiKeysPlugin::new();
184        let created = plugin.create_key("test-key", "user-1", vec!["read".into(), "write".into()]);
185        assert!(created.raw_key.starts_with("pylon_"));
186
187        let ctx = plugin.resolve(&created.raw_key).unwrap();
188        assert_eq!(ctx.user_id, Some("user-1".into()));
189    }
190
191    #[test]
192    fn raw_key_not_stored() {
193        let plugin = ApiKeysPlugin::new();
194        let created = plugin.create_key("test-key", "user-1", vec![]);
195        let keys = plugin.keys.lock().unwrap();
196        // The HashMap key should be a hash, not the raw key.
197        assert!(!keys.contains_key(&created.raw_key));
198        // The stored ApiKey should not contain the raw key in any field.
199        let stored = keys.values().next().unwrap();
200        assert_ne!(stored.key_hash, created.raw_key);
201        assert_eq!(stored.key_hash, hash_key(&created.raw_key));
202    }
203
204    #[test]
205    fn resolve_invalid_key() {
206        let plugin = ApiKeysPlugin::new();
207        assert!(plugin.resolve("invalid").is_none());
208    }
209
210    #[test]
211    fn scope_checking() {
212        let plugin = ApiKeysPlugin::new();
213        let created = plugin.create_key("test", "user-1", vec!["read".into()]);
214
215        assert!(plugin.has_scope(&created.raw_key, "read"));
216        assert!(!plugin.has_scope(&created.raw_key, "write"));
217    }
218
219    #[test]
220    fn wildcard_scope() {
221        let plugin = ApiKeysPlugin::new();
222        let created = plugin.create_key("admin", "user-1", vec!["*".into()]);
223
224        assert!(plugin.has_scope(&created.raw_key, "read"));
225        assert!(plugin.has_scope(&created.raw_key, "write"));
226        assert!(plugin.has_scope(&created.raw_key, "anything"));
227    }
228
229    #[test]
230    fn empty_scopes_allows_all() {
231        let plugin = ApiKeysPlugin::new();
232        let created = plugin.create_key("full-access", "user-1", vec![]);
233
234        assert!(plugin.has_scope(&created.raw_key, "read"));
235        assert!(plugin.has_scope(&created.raw_key, "write"));
236    }
237
238    #[test]
239    fn revoke_key() {
240        let plugin = ApiKeysPlugin::new();
241        let created = plugin.create_key("test", "user-1", vec![]);
242
243        assert!(plugin.revoke(&created.raw_key));
244        assert!(plugin.resolve(&created.raw_key).is_none());
245        assert!(!plugin.revoke(&created.raw_key)); // already revoked
246    }
247
248    #[test]
249    fn is_active_tracks_revocation() {
250        let plugin = ApiKeysPlugin::new();
251        let created = plugin.create_key("test", "user-1", vec![]);
252        assert!(plugin.is_active(&created.raw_key));
253        plugin.revoke(&created.raw_key);
254        assert!(!plugin.is_active(&created.raw_key));
255    }
256
257    #[test]
258    fn list_keys_by_user() {
259        let plugin = ApiKeysPlugin::new();
260        plugin.create_key("key1", "user-1", vec![]);
261        plugin.create_key("key2", "user-1", vec![]);
262        plugin.create_key("key3", "user-2", vec![]);
263
264        let keys = plugin.list_keys("user-1");
265        assert_eq!(keys.len(), 2);
266
267        let keys = plugin.list_keys("user-2");
268        assert_eq!(keys.len(), 1);
269    }
270
271    #[test]
272    fn generated_keys_are_unique() {
273        let k1 = generate_key();
274        let k2 = generate_key();
275        assert_ne!(k1, k2);
276        assert!(k1.starts_with("pylon_"));
277        // "pylon_" (6) + 48 hex chars (24 bytes) = 54
278        assert_eq!(k1.len(), 54);
279    }
280
281    #[test]
282    fn hash_key_is_deterministic() {
283        let h1 = hash_key("test_key_123");
284        let h2 = hash_key("test_key_123");
285        assert_eq!(h1, h2);
286    }
287
288    #[test]
289    fn hash_key_differs_for_different_inputs() {
290        let h1 = hash_key("key_a");
291        let h2 = hash_key("key_b");
292        assert_ne!(h1, h2);
293    }
294
295    #[test]
296    fn hash_key_is_sha256_length() {
297        let h = hash_key("test");
298        // SHA-256 = 32 bytes = 64 hex chars
299        assert_eq!(h.len(), 64);
300    }
301}