Skip to main content

authx_plugins/api_key/
service.rs

1use chrono::{DateTime, Duration, Utc};
2use rand::Rng;
3use tracing::instrument;
4use uuid::Uuid;
5
6use authx_core::{
7    crypto::sha256_hex,
8    error::{AuthError, Result},
9    models::{ApiKey, CreateApiKey},
10};
11use authx_storage::ports::{ApiKeyRepository, UserRepository};
12
13/// Maximum lifetime for an API key.
14const MAX_KEY_TTL: Duration = Duration::days(365);
15
16/// Returned when an API key is first created — the `raw_key` is shown once only.
17#[derive(Debug)]
18pub struct ApiKeyResponse {
19    pub key: ApiKey,
20    pub raw_key: String,
21}
22
23pub struct ApiKeyService<S> {
24    storage: S,
25}
26
27impl<S> ApiKeyService<S>
28where
29    S: UserRepository + ApiKeyRepository + Clone + Send + Sync + 'static,
30{
31    pub fn new(storage: S) -> Self {
32        Self { storage }
33    }
34
35    /// Create a new API key for `user_id`. Returns the `ApiKey` row and the
36    /// raw key (hex-encoded 32 random bytes). The raw key is **never stored**;
37    /// only the SHA-256 hash is persisted.
38    ///
39    /// `expires_at` is required and must be at most [`MAX_KEY_TTL`] (365 days) in the future.
40    #[instrument(skip(self), fields(user_id = %user_id))]
41    pub async fn create(
42        &self,
43        user_id: Uuid,
44        org_id: Option<Uuid>,
45        name: String,
46        scopes: Vec<String>,
47        expires_at: DateTime<Utc>,
48    ) -> Result<ApiKeyResponse> {
49        let now = Utc::now();
50        if expires_at <= now {
51            return Err(AuthError::Internal(
52                "api key expiry must be in the future".into(),
53            ));
54        }
55        let max_expiry = now + MAX_KEY_TTL;
56        if expires_at > max_expiry {
57            return Err(AuthError::Internal(format!(
58                "api key expiry exceeds maximum allowed ({} days)",
59                MAX_KEY_TTL.num_days()
60            )));
61        }
62
63        // Verify user exists.
64        UserRepository::find_by_id(&self.storage, user_id)
65            .await?
66            .ok_or(AuthError::UserNotFound)?;
67
68        let raw: [u8; 32] = rand::thread_rng().r#gen();
69        let raw_key = hex::encode(raw);
70        let key_hash = sha256_hex(raw_key.as_bytes());
71        let prefix = raw_key[..8].to_owned();
72
73        let key = ApiKeyRepository::create(
74            &self.storage,
75            CreateApiKey {
76                user_id,
77                org_id,
78                key_hash,
79                prefix,
80                name,
81                scopes,
82                expires_at: Some(expires_at),
83            },
84        )
85        .await?;
86
87        tracing::info!(user_id = %user_id, key_id = %key.id, "api key created");
88        Ok(ApiKeyResponse { key, raw_key })
89    }
90
91    /// List all API keys belonging to `user_id`.
92    #[instrument(skip(self), fields(user_id = %user_id))]
93    pub async fn list(&self, user_id: Uuid) -> Result<Vec<ApiKey>> {
94        let keys = ApiKeyRepository::find_by_user(&self.storage, user_id).await?;
95        tracing::debug!(user_id = %user_id, count = keys.len(), "api keys listed");
96        Ok(keys)
97    }
98
99    /// Revoke (delete) an API key. Enforces that the key belongs to `user_id`.
100    #[instrument(skip(self), fields(user_id = %user_id, key_id = %key_id))]
101    pub async fn revoke(&self, user_id: Uuid, key_id: Uuid) -> Result<()> {
102        ApiKeyRepository::revoke(&self.storage, key_id, user_id).await?;
103        tracing::info!(user_id = %user_id, key_id = %key_id, "api key revoked");
104        Ok(())
105    }
106
107    /// Authenticate using a raw API key string.
108    ///
109    /// Returns `Err(AuthError::InvalidToken)` if the key is unknown, expired,
110    /// or otherwise invalid. On success, updates `last_used_at`.
111    #[instrument(skip(self, raw_key))]
112    pub async fn authenticate(&self, raw_key: &str) -> Result<ApiKey> {
113        let key_hash = sha256_hex(raw_key.as_bytes());
114        let key = ApiKeyRepository::find_by_hash(&self.storage, &key_hash)
115            .await?
116            .ok_or(AuthError::InvalidToken)?;
117
118        if let Some(exp) = key.expires_at
119            && exp < Utc::now()
120        {
121            tracing::warn!(key_id = %key.id, "api key expired");
122            return Err(AuthError::InvalidToken);
123        }
124
125        let now = Utc::now();
126        ApiKeyRepository::touch_last_used(&self.storage, key.id, now).await?;
127        tracing::info!(key_id = %key.id, user_id = %key.user_id, "api key authenticated");
128        Ok(ApiKey {
129            last_used_at: Some(now),
130            ..key
131        })
132    }
133}