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