1use argon2::password_hash::{rand_core::OsRng, SaltString};
6use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use subtle::ConstantTimeEq;
11use thiserror::Error;
12use uuid::Uuid;
13
14#[derive(Debug, Error)]
16pub enum ApiKeyError {
17 #[error("Storage error: {0}")]
18 Storage(String),
19 #[error("Key not found")]
20 NotFound,
21 #[error("Key expired")]
22 Expired,
23 #[error("Key revoked")]
24 Revoked,
25 #[error("Invalid key format")]
26 InvalidFormat,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ApiKeyRecord {
32 pub id: Uuid,
34 pub key_hash: String,
36 pub key_prefix: String,
38 pub user_id: String,
40 pub name: String,
42 pub scopes: Vec<String>,
44 pub created_at: DateTime<Utc>,
46 pub expires_at: Option<DateTime<Utc>>,
48 pub last_used_at: Option<DateTime<Utc>>,
50 pub revoked: bool,
52}
53
54impl ApiKeyRecord {
55 pub fn new(
58 user_id: &str,
59 name: &str,
60 scopes: Vec<String>,
61 expires_in_days: Option<u32>,
62 ) -> (Self, String) {
63 let id = Uuid::new_v4();
64
65 use rand::distributions::{Alphanumeric, DistString};
67 let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), 32);
68 let plaintext_key = format!("vex_{}_{}", id.to_string().replace("-", ""), random_part);
69
70 let key_hash = Self::hash_key(&plaintext_key);
72 let key_prefix = plaintext_key.chars().take(12).collect();
73
74 let expires_at =
75 expires_in_days.map(|days| Utc::now() + chrono::Duration::days(days as i64));
76
77 let record = Self {
78 id,
79 key_hash,
80 key_prefix,
81 user_id: user_id.to_string(),
82 name: name.to_string(),
83 scopes,
84 created_at: Utc::now(),
85 expires_at,
86 last_used_at: None,
87 revoked: false,
88 };
89
90 (record, plaintext_key)
91 }
92
93 pub fn hash_key(key: &str) -> String {
96 let salt = SaltString::generate(&mut OsRng);
97 let argon2 = Argon2::default();
98 argon2
99 .hash_password(key.as_bytes(), &salt)
100 .expect("Argon2 hashing should not fail")
101 .to_string()
102 }
103
104 pub fn verify_key(plaintext_key: &str, stored_hash: &str) -> bool {
107 match PasswordHash::new(stored_hash) {
108 Ok(parsed_hash) => Argon2::default()
109 .verify_password(plaintext_key.as_bytes(), &parsed_hash)
110 .is_ok(),
111 Err(_) => {
112 let legacy_hash = {
115 use sha2::{Digest, Sha256};
116 let mut hasher = Sha256::new();
117 hasher.update(plaintext_key.as_bytes());
118 hex::encode(hasher.finalize())
119 };
120 legacy_hash.as_bytes().ct_eq(stored_hash.as_bytes()).into()
121 }
122 }
123 }
124
125 pub fn is_valid(&self) -> bool {
127 if self.revoked {
128 return false;
129 }
130 if let Some(expires) = self.expires_at {
131 if Utc::now() > expires {
132 return false;
133 }
134 }
135 true
136 }
137
138 pub fn has_scope(&self, scope: &str) -> bool {
140 self.scopes.iter().any(|s| s == scope || s == "*")
141 }
142}
143
144#[async_trait]
146pub trait ApiKeyStore: Send + Sync {
147 async fn create(&self, record: &ApiKeyRecord) -> Result<(), ApiKeyError>;
149
150 async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKeyRecord>, ApiKeyError>;
152
153 async fn find_and_verify_key(
156 &self,
157 plaintext_key: &str,
158 ) -> Result<Option<ApiKeyRecord>, ApiKeyError>;
159
160 async fn find_by_user(&self, user_id: &str) -> Result<Vec<ApiKeyRecord>, ApiKeyError>;
162
163 async fn record_usage(&self, id: Uuid) -> Result<(), ApiKeyError>;
165
166 async fn revoke(&self, id: Uuid) -> Result<(), ApiKeyError>;
168
169 async fn delete(&self, id: Uuid) -> Result<(), ApiKeyError>;
171
172 async fn rotate(
181 &self,
182 old_key_id: Uuid,
183 expires_in_days: Option<u32>,
184 ) -> Result<(ApiKeyRecord, String), ApiKeyError>;
185}
186
187#[derive(Debug, Default)]
189pub struct MemoryApiKeyStore {
190 keys: tokio::sync::RwLock<std::collections::HashMap<Uuid, ApiKeyRecord>>,
191}
192
193impl MemoryApiKeyStore {
194 pub fn new() -> Self {
195 Self::default()
196 }
197}
198
199#[async_trait]
200impl ApiKeyStore for MemoryApiKeyStore {
201 async fn create(&self, record: &ApiKeyRecord) -> Result<(), ApiKeyError> {
202 let mut keys = self.keys.write().await;
203 keys.insert(record.id, record.clone());
204 Ok(())
205 }
206
207 async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKeyRecord>, ApiKeyError> {
208 let keys = self.keys.read().await;
210 Ok(keys.values().find(|r| r.key_hash == hash).cloned())
211 }
212
213 async fn find_and_verify_key(
214 &self,
215 plaintext_key: &str,
216 ) -> Result<Option<ApiKeyRecord>, ApiKeyError> {
217 let parts: Vec<&str> = plaintext_key.split('_').collect();
219 if parts.len() < 3 {
220 return Err(ApiKeyError::InvalidFormat);
221 }
222
223 let uuid_str = parts[1];
224 if uuid_str.len() != 32 {
225 return Err(ApiKeyError::InvalidFormat);
226 }
227
228 let formatted_uuid = format!(
230 "{}-{}-{}-{}-{}",
231 &uuid_str[0..8],
232 &uuid_str[8..12],
233 &uuid_str[12..16],
234 &uuid_str[16..20],
235 &uuid_str[20..32]
236 );
237
238 let id = Uuid::parse_str(&formatted_uuid).map_err(|_| ApiKeyError::InvalidFormat)?;
239
240 let keys = self.keys.read().await;
241 if let Some(record) = keys.get(&id) {
242 if ApiKeyRecord::verify_key(plaintext_key, &record.key_hash) {
243 return Ok(Some(record.clone()));
244 }
245 }
246
247 Ok(None)
248 }
249
250 async fn find_by_user(&self, user_id: &str) -> Result<Vec<ApiKeyRecord>, ApiKeyError> {
251 let keys = self.keys.read().await;
252 Ok(keys
253 .values()
254 .filter(|r| r.user_id == user_id)
255 .cloned()
256 .collect())
257 }
258
259 async fn record_usage(&self, id: Uuid) -> Result<(), ApiKeyError> {
260 let mut keys = self.keys.write().await;
261 if let Some(record) = keys.get_mut(&id) {
262 record.last_used_at = Some(Utc::now());
263 Ok(())
264 } else {
265 Err(ApiKeyError::NotFound)
266 }
267 }
268
269 async fn revoke(&self, id: Uuid) -> Result<(), ApiKeyError> {
270 let mut keys = self.keys.write().await;
271 if let Some(record) = keys.get_mut(&id) {
272 record.revoked = true;
273 Ok(())
274 } else {
275 Err(ApiKeyError::NotFound)
276 }
277 }
278
279 async fn delete(&self, id: Uuid) -> Result<(), ApiKeyError> {
280 let mut keys = self.keys.write().await;
281 keys.remove(&id).ok_or(ApiKeyError::NotFound)?;
282 Ok(())
283 }
284
285 async fn rotate(
286 &self,
287 old_key_id: Uuid,
288 expires_in_days: Option<u32>,
289 ) -> Result<(ApiKeyRecord, String), ApiKeyError> {
290 let old_key = {
292 let keys = self.keys.read().await;
293 keys.get(&old_key_id)
294 .cloned()
295 .ok_or(ApiKeyError::NotFound)?
296 };
297
298 self.revoke(old_key_id).await?;
300
301 let ttl = expires_in_days.unwrap_or(90); let (new_record, plaintext) = ApiKeyRecord::new(
304 &old_key.user_id,
305 &format!("{} (rotated)", old_key.name),
306 old_key.scopes.clone(),
307 Some(ttl),
308 );
309
310 self.create(&new_record).await?;
312
313 tracing::info!(
314 old_key_id = %old_key_id,
315 new_key_id = %new_record.id,
316 user_id = %old_key.user_id,
317 "API key rotated successfully"
318 );
319
320 Ok((new_record, plaintext))
321 }
322}
323
324pub async fn validate_api_key<S: ApiKeyStore>(
326 store: &S,
327 plaintext_key: &str,
328) -> Result<ApiKeyRecord, ApiKeyError> {
329 if !plaintext_key.starts_with("vex_") || plaintext_key.len() < 40 {
331 return Err(ApiKeyError::InvalidFormat);
332 }
333
334 let record = store
336 .find_and_verify_key(plaintext_key)
337 .await?
338 .ok_or(ApiKeyError::NotFound)?;
339
340 if record.revoked {
342 return Err(ApiKeyError::Revoked);
343 }
344 if let Some(expires) = record.expires_at {
345 if Utc::now() > expires {
346 return Err(ApiKeyError::Expired);
347 }
348 }
349
350 let _ = store.record_usage(record.id).await;
352
353 Ok(record)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[tokio::test]
361 async fn test_api_key_creation() {
362 let (record, key) =
363 ApiKeyRecord::new("user123", "My API Key", vec!["read".to_string()], None);
364
365 assert!(key.starts_with("vex_"));
366 assert!(key.len() > 40);
367 assert_eq!(record.user_id, "user123");
368 assert_eq!(record.name, "My API Key");
369 assert!(record.is_valid());
370 }
371
372 #[tokio::test]
373 async fn test_api_key_hash_verification() {
374 let key = "vex_test123456789_abcdefghijklmnopqrst";
377 let hash = ApiKeyRecord::hash_key(key);
378
379 assert!(ApiKeyRecord::verify_key(key, &hash));
381
382 assert!(!ApiKeyRecord::verify_key(
384 "vex_wrong_key_12345678901234567890",
385 &hash
386 ));
387 }
388
389 #[tokio::test]
390 async fn test_memory_store_crud() {
391 let store = MemoryApiKeyStore::new();
392 let (record, key) = ApiKeyRecord::new("user1", "Test Key", vec![], None);
393
394 store.create(&record).await.unwrap();
396
397 let found = store.find_and_verify_key(&key).await.unwrap();
399 assert!(found.is_some());
400 assert_eq!(found.unwrap().id, record.id);
401
402 store.revoke(record.id).await.unwrap();
404 let revoked = store.find_and_verify_key(&key).await.unwrap().unwrap();
405 assert!(revoked.revoked);
406 }
407
408 #[tokio::test]
409 async fn test_validate_api_key() {
410 let store = MemoryApiKeyStore::new();
411 let (record, key) = ApiKeyRecord::new("user1", "Test Key", vec!["admin".to_string()], None);
412 store.create(&record).await.unwrap();
413
414 let validated = validate_api_key(&store, &key).await.unwrap();
416 assert_eq!(validated.id, record.id);
417 assert!(validated.has_scope("admin"));
418
419 let result = validate_api_key(&store, "invalid").await;
421 assert!(matches!(result, Err(ApiKeyError::InvalidFormat)));
422
423 let result =
425 validate_api_key(&store, "vex_00000000000000000000000000000000_wrongkey").await;
426 assert!(matches!(result, Err(ApiKeyError::NotFound)));
427 }
428}