Skip to main content

modo/auth/apikey/
store.rs

1use std::sync::Arc;
2
3use chrono::Utc;
4
5use crate::db::Database;
6use crate::error::{Error, Result};
7use crate::id;
8
9use super::backend::ApiKeyBackend;
10use super::config::ApiKeyConfig;
11use super::sqlite::SqliteBackend;
12use super::token;
13use super::types::{ApiKeyCreated, ApiKeyMeta, ApiKeyRecord, CreateKeyRequest};
14
15/// UTC timestamp in ISO 8601 format with millisecond precision.
16fn now_utc() -> String {
17    Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
18}
19
20struct Inner {
21    backend: Arc<dyn ApiKeyBackend>,
22    config: ApiKeyConfig,
23}
24
25/// Tenant-scoped API key store.
26///
27/// Handles key generation, SHA-256 hashing, constant-time verification,
28/// touch throttling, and delegates storage to the backend. Cheap to clone
29/// (wraps `Arc`).
30///
31/// # Example
32///
33/// ```rust,no_run
34/// # fn example(db: modo::db::Database) {
35/// use modo::auth::apikey::{ApiKeyConfig, ApiKeyStore};
36///
37/// let store = ApiKeyStore::new(db, ApiKeyConfig::default()).unwrap();
38/// # }
39/// ```
40pub struct ApiKeyStore(Arc<Inner>);
41
42impl Clone for ApiKeyStore {
43    fn clone(&self) -> Self {
44        Self(Arc::clone(&self.0))
45    }
46}
47
48impl ApiKeyStore {
49    /// Create from the built-in SQLite backend.
50    ///
51    /// Validates config at construction — fails fast on invalid prefix or
52    /// secret length.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if [`ApiKeyConfig::validate`] fails.
57    pub fn new(db: Database, config: ApiKeyConfig) -> Result<Self> {
58        config.validate()?;
59        Ok(Self(Arc::new(Inner {
60            backend: Arc::new(SqliteBackend::new(db)),
61            config,
62        })))
63    }
64
65    /// Create from a custom backend.
66    ///
67    /// Validates config at construction.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if [`ApiKeyConfig::validate`] fails.
72    pub fn from_backend(backend: Arc<dyn ApiKeyBackend>, config: ApiKeyConfig) -> Result<Self> {
73        config.validate()?;
74        Ok(Self(Arc::new(Inner { backend, config })))
75    }
76
77    /// Create a new API key. Returns the raw token (shown once).
78    ///
79    /// # Errors
80    ///
81    /// Returns `bad_request` if `tenant_id` or `name` is empty, or if
82    /// `expires_at` is not a valid RFC 3339 timestamp. Propagates backend
83    /// storage errors.
84    pub async fn create(&self, req: &CreateKeyRequest) -> Result<ApiKeyCreated> {
85        if req.tenant_id.is_empty() {
86            return Err(Error::bad_request("tenant_id is required"));
87        }
88        if req.name.is_empty() {
89            return Err(Error::bad_request("name is required"));
90        }
91        if let Some(ref exp) = req.expires_at {
92            chrono::DateTime::parse_from_rfc3339(exp)
93                .map_err(|_| Error::bad_request("expires_at must be a valid RFC 3339 timestamp"))?;
94        }
95
96        let ulid = id::ulid();
97        let secret = token::generate_secret(self.0.config.secret_length);
98        let raw_token = token::format_token(&self.0.config.prefix, &ulid, &secret);
99        let key_hash = token::hash_secret(&secret);
100        let now = now_utc();
101
102        let record = ApiKeyRecord {
103            id: ulid.clone(),
104            key_hash,
105            tenant_id: req.tenant_id.clone(),
106            name: req.name.clone(),
107            scopes: req.scopes.clone(),
108            expires_at: req.expires_at.clone(),
109            last_used_at: None,
110            created_at: now.clone(),
111            revoked_at: None,
112        };
113
114        self.0.backend.store(&record).await?;
115
116        Ok(ApiKeyCreated {
117            id: ulid,
118            raw_token,
119            name: req.name.clone(),
120            scopes: req.scopes.clone(),
121            tenant_id: req.tenant_id.clone(),
122            expires_at: req.expires_at.clone(),
123            created_at: now,
124        })
125    }
126
127    /// Verify a raw token. Returns metadata if valid.
128    ///
129    /// All failure cases return the same generic `unauthorized` error to
130    /// prevent enumeration.
131    ///
132    /// # Errors
133    ///
134    /// Returns `unauthorized` if the token is malformed, not found, revoked,
135    /// expired, or the hash does not match. Propagates backend lookup errors.
136    pub async fn verify(&self, raw_token: &str) -> Result<ApiKeyMeta> {
137        // Run hash compare against a dummy for missing/revoked/expired paths
138        // so timing doesn't distinguish between "key id unknown" and "wrong secret".
139        // Note: malformed tokens (parse_token failure) still skip the DB round-trip
140        // and so remain timing-distinguishable from valid-format-but-unknown ids.
141        const DUMMY_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
142
143        let parsed = token::parse_token(raw_token, &self.0.config.prefix);
144        let record = match parsed.as_ref() {
145            Some(p) => self.0.backend.lookup(p.id).await?,
146            None => None,
147        };
148
149        let unusable = record
150            .as_ref()
151            .map(|r| {
152                r.revoked_at.is_some()
153                    || r.expires_at
154                        .as_deref()
155                        .map(expires_at_passed)
156                        .unwrap_or(false)
157            })
158            .unwrap_or(true);
159
160        let secret = parsed.as_ref().map(|p| p.secret).unwrap_or("");
161        let stored_hash = record
162            .as_ref()
163            .map(|r| r.key_hash.as_str())
164            .unwrap_or(DUMMY_HASH);
165        let hash_ok = token::verify_hash(secret, stored_hash);
166
167        if unusable || !hash_ok {
168            return Err(Error::unauthorized("invalid API key"));
169        }
170        let record = record.expect("hash_ok implies record");
171
172        self.maybe_touch(&record);
173
174        Ok(record.into_meta())
175    }
176
177    /// Revoke a key by ID.
178    ///
179    /// # Errors
180    ///
181    /// Returns `not_found` if no key with the given ID exists.
182    /// Propagates backend errors.
183    pub async fn revoke(&self, key_id: &str) -> Result<()> {
184        self.0
185            .backend
186            .lookup(key_id)
187            .await?
188            .ok_or_else(|| Error::not_found("API key not found"))?;
189
190        self.0.backend.revoke(key_id, &now_utc()).await
191    }
192
193    /// List all active keys for a tenant (no secrets).
194    ///
195    /// # Errors
196    ///
197    /// Propagates backend errors.
198    pub async fn list(&self, tenant_id: &str) -> Result<Vec<ApiKeyMeta>> {
199        let records = self.0.backend.list(tenant_id).await?;
200        Ok(records.into_iter().map(ApiKeyRecord::into_meta).collect())
201    }
202
203    /// Update `expires_at` (refresh/extend a key).
204    ///
205    /// # Errors
206    ///
207    /// Returns `bad_request` if `expires_at` is not a valid RFC 3339
208    /// timestamp. Returns `not_found` if no key with the given ID exists.
209    /// Propagates backend errors.
210    pub async fn refresh(&self, key_id: &str, expires_at: Option<&str>) -> Result<()> {
211        if let Some(exp) = expires_at {
212            chrono::DateTime::parse_from_rfc3339(exp)
213                .map_err(|_| Error::bad_request("expires_at must be a valid RFC 3339 timestamp"))?;
214        }
215
216        self.0
217            .backend
218            .lookup(key_id)
219            .await?
220            .ok_or_else(|| Error::not_found("API key not found"))?;
221
222        self.0.backend.update_expires_at(key_id, expires_at).await
223    }
224
225    /// Fire-and-forget touch if the threshold has elapsed.
226    ///
227    /// Best-effort: the spawned task may be lost on shutdown.
228    fn maybe_touch(&self, record: &ApiKeyRecord) {
229        let threshold_secs = self.0.config.touch_threshold_secs;
230        let should_touch = match &record.last_used_at {
231            None => true,
232            Some(last) => match chrono::DateTime::parse_from_rfc3339(last) {
233                Ok(last_dt) => {
234                    let elapsed = chrono::Utc::now()
235                        .signed_duration_since(last_dt)
236                        .num_seconds();
237                    elapsed >= threshold_secs as i64
238                }
239                Err(_) => true,
240            },
241        };
242
243        if should_touch {
244            let backend = self.0.backend.clone();
245            let key_id = record.id.clone();
246            tokio::spawn(async move {
247                if let Err(e) = backend.update_last_used(&key_id, &now_utc()).await {
248                    tracing::warn!(key_id, error = %e, "failed to update API key last_used_at");
249                }
250            });
251        }
252    }
253}
254
255fn expires_at_passed(rfc3339: &str) -> bool {
256    chrono::DateTime::parse_from_rfc3339(rfc3339)
257        .map(|dt| dt <= Utc::now())
258        .unwrap_or(true)
259}