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 subtle::ConstantTimeEq;
10use systemprompt_database::DbPool;
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: UserRepository,
31}
32
33impl ApiKeyService {
34    pub fn new(db: &DbPool) -> Result<Self> {
35        Ok(Self {
36            repository: UserRepository::new(db)?,
37        })
38    }
39
40    pub async fn issue(&self, params: IssueApiKeyParams<'_>) -> Result<NewApiKey> {
41        let trimmed = params.name.trim();
42        if trimmed.is_empty() {
43            return Err(UserError::Validation(
44                "api key name must not be empty".into(),
45            ));
46        }
47
48        let id = ApiKeyId::generate();
49        let (secret, key_prefix, key_hash) = generate_secret();
50
51        let record = self
52            .repository
53            .create_api_key(CreateApiKeyParams {
54                id: &id,
55                user_id: params.user_id,
56                name: trimmed,
57                key_prefix: &key_prefix,
58                key_hash: &key_hash,
59                expires_at: params.expires_at,
60            })
61            .await?;
62
63        Ok(NewApiKey { record, secret })
64    }
65
66    pub async fn verify(&self, presented_secret: &str) -> Result<Option<UserApiKey>> {
67        let Some(key_prefix) = extract_prefix(presented_secret) else {
68            return Ok(None);
69        };
70
71        let Some(record) = self
72            .repository
73            .find_active_api_key_by_prefix(&key_prefix)
74            .await?
75        else {
76            return Ok(None);
77        };
78
79        if !record.is_active(Utc::now()) {
80            return Ok(None);
81        }
82
83        let presented_hash = hash_secret(presented_secret);
84        if presented_hash
85            .as_bytes()
86            .ct_eq(record.key_hash.as_bytes())
87            .into()
88        {
89            self.repository.touch_api_key_usage(&record.id).await?;
90            Ok(Some(record))
91        } else {
92            Ok(None)
93        }
94    }
95
96    pub async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<UserApiKey>> {
97        self.repository.list_api_keys_for_user(user_id).await
98    }
99
100    pub async fn revoke(&self, id: &ApiKeyId, user_id: &UserId) -> Result<bool> {
101        self.repository.revoke_api_key(id, user_id).await
102    }
103}
104
105fn generate_secret() -> (String, String, String) {
106    let mut raw = [0u8; SECRET_BYTES];
107    rand::rng().fill_bytes(&mut raw);
108    let encoded = hex::encode(raw);
109    let key_prefix = format!("{API_KEY_PREFIX}{}", &encoded[..PREFIX_ID_BYTES * 2]);
110    let secret = format!("{key_prefix}.{}", &encoded[PREFIX_ID_BYTES * 2..]);
111    let key_hash = hash_secret(&secret);
112    (secret, key_prefix, key_hash)
113}
114
115fn hash_secret(secret: &str) -> String {
116    let mut hasher = Sha256::new();
117    hasher.update(secret.as_bytes());
118    hex::encode(hasher.finalize())
119}
120
121fn extract_prefix(presented: &str) -> Option<String> {
122    if !presented.starts_with(API_KEY_PREFIX) {
123        return None;
124    }
125    let dot = presented.find('.')?;
126    Some(presented[..dot].to_string())
127}