Skip to main content

systemprompt_users/services/
api_key_service.rs

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