Skip to main content

systemprompt_users/services/
api_key_service.rs

1//! API-key minting: prefixed secrets with stored hashes.
2//!
3//! A key's `purpose` is bound by its issuer (for example `workflow:<id>`) and
4//! is `None` for a person's own key.
5//!
6//! Copyright (c) systemprompt.io — Business Source License 1.1.
7//! See <https://systemprompt.io> for licensing details.
8
9use chrono::{DateTime, Utc};
10use rand::Rng;
11use sha2::{Digest, Sha256};
12use std::sync::Arc;
13use subtle::ConstantTimeEq;
14use systemprompt_identifiers::{ApiKeyId, UserId};
15use systemprompt_models::attribution::ScopeBinding;
16
17use crate::error::{Result, UserError};
18use crate::models::{ApiKeyLimits, NewApiKey, UserApiKey};
19use crate::repository::{CreateApiKeyParams, UserRepository};
20
21pub const API_KEY_PREFIX: &str = "sp-live-";
22const SECRET_BYTES: usize = 32;
23const PREFIX_ID_BYTES: usize = 6;
24const USAGE_TOUCH_INTERVAL: chrono::TimeDelta = chrono::TimeDelta::seconds(60);
25
26#[derive(Debug, Clone)]
27pub struct IssueApiKeyParams<'a> {
28    pub user_id: &'a UserId,
29    pub name: &'a str,
30    pub expires_at: Option<DateTime<Utc>>,
31    pub limits: &'a ApiKeyLimits,
32    pub scopes: &'a [ScopeBinding],
33    pub purpose: Option<&'a str>,
34}
35
36#[derive(Debug, Clone)]
37pub struct ApiKeyService {
38    repository: Arc<UserRepository>,
39}
40
41impl ApiKeyService {
42    pub const fn new(repository: Arc<UserRepository>) -> Self {
43        Self { repository }
44    }
45
46    pub async fn issue(&self, params: IssueApiKeyParams<'_>) -> Result<NewApiKey> {
47        let trimmed = params.name.trim();
48        if trimmed.is_empty() {
49            return Err(UserError::Validation(
50                "api key name must not be empty".into(),
51            ));
52        }
53
54        validate_limits(params.limits)?;
55        validate_scopes(params.scopes)?;
56
57        let id = ApiKeyId::generate();
58        let (secret, key_prefix, key_hash) = generate_secret();
59
60        let record = self
61            .repository
62            .create_api_key(CreateApiKeyParams {
63                id: &id,
64                user_id: params.user_id,
65                name: trimmed,
66                key_prefix: &key_prefix,
67                key_hash: &key_hash,
68                expires_at: params.expires_at,
69                limits: params.limits,
70                scopes: params.scopes,
71                purpose: params.purpose,
72            })
73            .await?;
74
75        Ok(NewApiKey { record, secret })
76    }
77
78    pub async fn verify(&self, presented_secret: &str) -> Result<Option<UserApiKey>> {
79        let Some(key_prefix) = extract_prefix(presented_secret) else {
80            return Ok(None);
81        };
82
83        let Some(record) = self
84            .repository
85            .find_active_api_key_by_prefix(&key_prefix)
86            .await?
87        else {
88            return Ok(None);
89        };
90
91        if !record.is_active(Utc::now()) {
92            return Ok(None);
93        }
94
95        let presented_hash = hash_secret(presented_secret);
96        if presented_hash
97            .as_bytes()
98            .ct_eq(record.key_hash.as_bytes())
99            .into()
100        {
101            if usage_is_stale(record.last_used_at, Utc::now()) {
102                self.repository.touch_api_key_usage(&record.id).await?;
103            }
104            Ok(Some(record))
105        } else {
106            Ok(None)
107        }
108    }
109
110    pub async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<UserApiKey>> {
111        self.repository.list_api_keys_for_user(user_id).await
112    }
113
114    pub async fn revoke(&self, id: &ApiKeyId, user_id: &UserId) -> Result<bool> {
115        self.repository.revoke_api_key(id, user_id).await
116    }
117}
118
119fn validate_limits(limits: &ApiKeyLimits) -> Result<()> {
120    if limits
121        .model_allowlist
122        .as_ref()
123        .is_some_and(|models| models.is_empty() || models.iter().any(|m| m.trim().is_empty()))
124    {
125        return Err(UserError::Validation(
126            "model_allowlist must name at least one model, or be omitted".into(),
127        ));
128    }
129    if limits.budget_microdollars.is_some_and(|b| b < 0)
130        || limits.max_requests.is_some_and(|m| m < 0)
131    {
132        return Err(UserError::Validation(
133            "budget_microdollars and max_requests must not be negative".into(),
134        ));
135    }
136    let ceiling = limits.budget_microdollars.is_some() || limits.max_requests.is_some();
137    if ceiling && limits.request_window_seconds.is_none_or(|w| w <= 0) {
138        return Err(UserError::Validation(
139            "budget_microdollars and max_requests need a positive request_window_seconds".into(),
140        ));
141    }
142    Ok(())
143}
144
145fn validate_scopes(scopes: &[ScopeBinding]) -> Result<()> {
146    for (index, scope) in scopes.iter().enumerate() {
147        if scope.value.trim().is_empty() {
148            return Err(UserError::Validation(format!(
149                "scope {} must bind a non-empty value",
150                scope.dimension
151            )));
152        }
153        if scopes[..index]
154            .iter()
155            .any(|s| s.dimension == scope.dimension)
156        {
157            return Err(UserError::Validation(format!(
158                "scope {} is bound more than once",
159                scope.dimension
160            )));
161        }
162    }
163    Ok(())
164}
165
166fn generate_secret() -> (String, String, String) {
167    let mut raw = [0u8; SECRET_BYTES];
168    rand::rng().fill_bytes(&mut raw);
169    let encoded = hex::encode(raw);
170    let key_prefix = format!("{API_KEY_PREFIX}{}", &encoded[..PREFIX_ID_BYTES * 2]);
171    let secret = format!("{key_prefix}.{}", &encoded[PREFIX_ID_BYTES * 2..]);
172    let key_hash = hash_secret(&secret);
173    (secret, key_prefix, key_hash)
174}
175
176fn hash_secret(secret: &str) -> String {
177    let mut hasher = Sha256::new();
178    hasher.update(secret.as_bytes());
179    hex::encode(hasher.finalize())
180}
181
182fn extract_prefix(presented: &str) -> Option<String> {
183    if !presented.starts_with(API_KEY_PREFIX) {
184        return None;
185    }
186    let dot = presented.find('.')?;
187    Some(presented[..dot].to_string())
188}
189
190fn usage_is_stale(last_used_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
191    last_used_at.is_none_or(|at| now - at >= USAGE_TOUCH_INTERVAL)
192}