systemprompt_users/services/
api_key_service.rs1use chrono::{DateTime, Utc};
7use rand::Rng;
8use sha2::{Digest, Sha256};
9use std::sync::Arc;
10use subtle::ConstantTimeEq;
11use systemprompt_identifiers::{ApiKeyId, UserId};
12
13use crate::error::{Result, UserError};
14use crate::models::{NewApiKey, UserApiKey};
15use crate::repository::{CreateApiKeyParams, UserRepository};
16
17pub const API_KEY_PREFIX: &str = "sp-live-";
18const SECRET_BYTES: usize = 32;
19const PREFIX_ID_BYTES: usize = 6;
20
21#[derive(Debug, Clone)]
22pub struct IssueApiKeyParams<'a> {
23 pub user_id: &'a UserId,
24 pub name: &'a str,
25 pub expires_at: Option<DateTime<Utc>>,
26}
27
28#[derive(Debug, Clone)]
29pub struct ApiKeyService {
30 repository: Arc<UserRepository>,
31}
32
33impl ApiKeyService {
34 pub const fn new(repository: Arc<UserRepository>) -> Self {
35 Self { repository }
36 }
37
38 pub async fn issue(&self, params: IssueApiKeyParams<'_>) -> Result<NewApiKey> {
39 let trimmed = params.name.trim();
40 if trimmed.is_empty() {
41 return Err(UserError::Validation(
42 "api key name must not be empty".into(),
43 ));
44 }
45
46 let id = ApiKeyId::generate();
47 let (secret, key_prefix, key_hash) = generate_secret();
48
49 let record = self
50 .repository
51 .create_api_key(CreateApiKeyParams {
52 id: &id,
53 user_id: params.user_id,
54 name: trimmed,
55 key_prefix: &key_prefix,
56 key_hash: &key_hash,
57 expires_at: params.expires_at,
58 })
59 .await?;
60
61 Ok(NewApiKey { record, secret })
62 }
63
64 pub async fn verify(&self, presented_secret: &str) -> Result<Option<UserApiKey>> {
65 let Some(key_prefix) = extract_prefix(presented_secret) else {
66 return Ok(None);
67 };
68
69 let Some(record) = self
70 .repository
71 .find_active_api_key_by_prefix(&key_prefix)
72 .await?
73 else {
74 return Ok(None);
75 };
76
77 if !record.is_active(Utc::now()) {
78 return Ok(None);
79 }
80
81 let presented_hash = hash_secret(presented_secret);
82 if presented_hash
83 .as_bytes()
84 .ct_eq(record.key_hash.as_bytes())
85 .into()
86 {
87 self.repository.touch_api_key_usage(&record.id).await?;
88 Ok(Some(record))
89 } else {
90 Ok(None)
91 }
92 }
93
94 pub async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<UserApiKey>> {
95 self.repository.list_api_keys_for_user(user_id).await
96 }
97
98 pub async fn revoke(&self, id: &ApiKeyId, user_id: &UserId) -> Result<bool> {
99 self.repository.revoke_api_key(id, user_id).await
100 }
101}
102
103fn generate_secret() -> (String, String, String) {
104 let mut raw = [0u8; SECRET_BYTES];
105 rand::rng().fill_bytes(&mut raw);
106 let encoded = hex::encode(raw);
107 let key_prefix = format!("{API_KEY_PREFIX}{}", &encoded[..PREFIX_ID_BYTES * 2]);
108 let secret = format!("{key_prefix}.{}", &encoded[PREFIX_ID_BYTES * 2..]);
109 let key_hash = hash_secret(&secret);
110 (secret, key_prefix, key_hash)
111}
112
113fn hash_secret(secret: &str) -> String {
114 let mut hasher = Sha256::new();
115 hasher.update(secret.as_bytes());
116 hex::encode(hasher.finalize())
117}
118
119fn extract_prefix(presented: &str) -> Option<String> {
120 if !presented.starts_with(API_KEY_PREFIX) {
121 return None;
122 }
123 let dot = presented.find('.')?;
124 Some(presented[..dot].to_string())
125}