systemprompt_users/services/
api_key_service.rs1use chrono::{DateTime, Utc};
16use rand::Rng;
17use sha2::{Digest, Sha256};
18use std::sync::Arc;
19use subtle::ConstantTimeEq;
20use systemprompt_identifiers::{ApiKeyId, UserId};
21use systemprompt_models::attribution::ScopeBinding;
22
23use crate::error::{Result, UserError};
24use crate::models::{ApiKeyKind, ApiKeyLimits, NewApiKey, UserApiKey};
25use crate::repository::{CreateApiKeyParams, UserRepository};
26
27pub const API_KEY_PREFIX: &str = "sp-live-";
28const SECRET_BYTES: usize = 32;
29const PREFIX_ID_BYTES: usize = 6;
30const USAGE_TOUCH_INTERVAL: chrono::TimeDelta = chrono::TimeDelta::seconds(60);
31
32#[derive(Debug, Clone)]
33pub struct IssueApiKeyParams<'a> {
34 pub user_id: &'a UserId,
35 pub name: &'a str,
36 pub expires_at: Option<DateTime<Utc>>,
37 pub limits: &'a ApiKeyLimits,
38 pub scopes: &'a [ScopeBinding],
39 pub purpose: Option<&'a str>,
40 pub kind: ApiKeyKind,
41}
42
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
45pub struct ApiKeyLifetime {
46 max_days: Option<u32>,
47}
48
49impl ApiKeyLifetime {
50 pub const fn unbounded() -> Self {
51 Self { max_days: None }
52 }
53
54 pub const fn from_max_days(max_days: Option<u32>) -> Self {
55 Self { max_days }
56 }
57
58 pub const fn max_days(self) -> Option<u32> {
59 self.max_days
60 }
61
62 pub fn bound(
63 self,
64 requested: Option<DateTime<Utc>>,
65 now: DateTime<Utc>,
66 ) -> Result<Option<DateTime<Utc>>> {
67 let Some(max_days) = self.max_days else {
68 return Ok(requested);
69 };
70 let ceiling = now + chrono::TimeDelta::days(i64::from(max_days));
71 match requested {
72 None => Ok(Some(ceiling)),
73 Some(at) if at > ceiling => Err(UserError::ApiKeyLifetimeExceeded {
74 requested: at,
75 max_days,
76 }),
77 Some(at) => Ok(Some(at)),
78 }
79 }
80
81 pub fn admits(self, created_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
82 let Some(max_days) = self.max_days else {
83 return true;
84 };
85 created_at.is_some_and(|at| now < at + chrono::TimeDelta::days(i64::from(max_days)))
86 }
87}
88
89#[derive(Debug)]
92pub enum EnsuredApiKey {
93 Existing(UserApiKey),
94 Issued(NewApiKey),
95}
96
97#[derive(Debug, Clone)]
98pub struct ApiKeyService {
99 repository: Arc<UserRepository>,
100 lifetime: ApiKeyLifetime,
101}
102
103impl ApiKeyService {
104 pub const fn new(repository: Arc<UserRepository>, lifetime: ApiKeyLifetime) -> Self {
105 Self {
106 repository,
107 lifetime,
108 }
109 }
110
111 pub async fn issue(&self, params: IssueApiKeyParams<'_>) -> Result<NewApiKey> {
112 let trimmed = params.name.trim();
113 if trimmed.is_empty() {
114 return Err(UserError::Validation(
115 "api key name must not be empty".into(),
116 ));
117 }
118
119 validate_limits(params.limits)?;
120 validate_scopes(params.scopes)?;
121 let expires_at = self.lifetime.bound(params.expires_at, Utc::now())?;
122
123 let id = ApiKeyId::generate();
124 let (secret, key_prefix, key_hash) = generate_secret();
125
126 let record = self
127 .repository
128 .create_api_key(CreateApiKeyParams {
129 id: &id,
130 user_id: params.user_id,
131 name: trimmed,
132 key_prefix: &key_prefix,
133 key_hash: &key_hash,
134 expires_at,
135 limits: params.limits,
136 scopes: params.scopes,
137 purpose: params.purpose,
138 kind: params.kind,
139 })
140 .await?;
141
142 Ok(NewApiKey { record, secret })
143 }
144
145 pub async fn ensure(
146 &self,
147 params: IssueApiKeyParams<'_>,
148 renew_before: chrono::TimeDelta,
149 ) -> Result<EnsuredApiKey> {
150 let name = params.name.trim();
151 let now = Utc::now();
152 let renew_at = now + renew_before;
153 let existing = self
154 .repository
155 .list_api_keys_for_user(params.user_id)
156 .await?
157 .into_iter()
158 .find(|key| {
159 key.name == name
160 && key.is_active(now)
161 && self.lifetime.admits(key.created_at, renew_at)
162 && key.expires_at.is_none_or(|at| at > renew_at)
163 });
164 match existing {
165 Some(key) => Ok(EnsuredApiKey::Existing(key)),
166 None => self.issue(params).await.map(EnsuredApiKey::Issued),
167 }
168 }
169
170 pub async fn verify(&self, presented_secret: &str) -> Result<Option<UserApiKey>> {
171 let Some(key_prefix) = extract_prefix(presented_secret) else {
172 return Ok(None);
173 };
174
175 let Some(record) = self
176 .repository
177 .find_active_api_key_by_prefix(&key_prefix)
178 .await?
179 else {
180 return Ok(None);
181 };
182
183 let now = Utc::now();
184 if !record.is_active(now) || !self.lifetime.admits(record.created_at, now) {
185 return Ok(None);
186 }
187
188 let presented_hash = hash_secret(presented_secret);
189 if presented_hash
190 .as_bytes()
191 .ct_eq(record.key_hash.as_bytes())
192 .into()
193 {
194 if usage_is_stale(record.last_used_at, Utc::now()) {
195 self.repository.touch_api_key_usage(&record.id).await?;
196 }
197 Ok(Some(record))
198 } else {
199 Ok(None)
200 }
201 }
202
203 pub async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<UserApiKey>> {
204 self.repository.list_api_keys_for_user(user_id).await
205 }
206
207 pub async fn revoke(&self, id: &ApiKeyId, user_id: &UserId) -> Result<bool> {
208 self.repository.revoke_api_key(id, user_id).await
209 }
210}
211
212fn validate_limits(limits: &ApiKeyLimits) -> Result<()> {
213 if limits
214 .model_allowlist
215 .as_ref()
216 .is_some_and(|models| models.is_empty() || models.iter().any(|m| m.trim().is_empty()))
217 {
218 return Err(UserError::Validation(
219 "model_allowlist must name at least one model, or be omitted".into(),
220 ));
221 }
222 if limits.budget_microdollars.is_some_and(|b| b < 0)
223 || limits.max_requests.is_some_and(|m| m < 0)
224 {
225 return Err(UserError::Validation(
226 "budget_microdollars and max_requests must not be negative".into(),
227 ));
228 }
229 let ceiling = limits.budget_microdollars.is_some() || limits.max_requests.is_some();
230 if ceiling && limits.request_window_seconds.is_none_or(|w| w <= 0) {
231 return Err(UserError::Validation(
232 "budget_microdollars and max_requests need a positive request_window_seconds".into(),
233 ));
234 }
235 Ok(())
236}
237
238fn validate_scopes(scopes: &[ScopeBinding]) -> Result<()> {
239 for (index, scope) in scopes.iter().enumerate() {
240 if scope.value.trim().is_empty() {
241 return Err(UserError::Validation(format!(
242 "scope {} must bind a non-empty value",
243 scope.dimension
244 )));
245 }
246 if scopes[..index]
247 .iter()
248 .any(|s| s.dimension == scope.dimension)
249 {
250 return Err(UserError::Validation(format!(
251 "scope {} is bound more than once",
252 scope.dimension
253 )));
254 }
255 }
256 Ok(())
257}
258
259fn generate_secret() -> (String, String, String) {
260 let mut raw = [0u8; SECRET_BYTES];
261 rand::rng().fill_bytes(&mut raw);
262 let encoded = hex::encode(raw);
263 let key_prefix = format!("{API_KEY_PREFIX}{}", &encoded[..PREFIX_ID_BYTES * 2]);
264 let secret = format!("{key_prefix}.{}", &encoded[PREFIX_ID_BYTES * 2..]);
265 let key_hash = hash_secret(&secret);
266 (secret, key_prefix, key_hash)
267}
268
269fn hash_secret(secret: &str) -> String {
270 let mut hasher = Sha256::new();
271 hasher.update(secret.as_bytes());
272 hex::encode(hasher.finalize())
273}
274
275fn extract_prefix(presented: &str) -> Option<String> {
276 if !presented.starts_with(API_KEY_PREFIX) {
277 return None;
278 }
279 let dot = presented.find('.')?;
280 Some(presented[..dot].to_string())
281}
282
283fn usage_is_stale(last_used_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
284 last_used_at.is_none_or(|at| now - at >= USAGE_TOUCH_INTERVAL)
285}