modo/auth/apikey/
store.rs1use 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
15fn 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
25pub 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 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 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 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 pub async fn verify(&self, raw_token: &str) -> Result<ApiKeyMeta> {
137 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 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 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 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 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}