1use std::fmt::Write as _;
29use std::sync::Arc;
30
31use axum::http::HeaderMap;
32use sea_query::{Alias, Expr, Query};
33use sha2::{Digest, Sha256};
34use time::OffsetDateTime;
35use time::format_description::well_known::Rfc3339;
36
37use crate::admin::{bearer_token, constant_time_eq};
38use crate::ports::{Clock, Database, DbError, Row, Statement};
39use crate::problem::Problem;
40use crate::problems::SLUGS;
41
42const ID_BYTES: usize = 8;
44const SECRET_BYTES: usize = 32;
47const MAX_NAMESPACE_CHARS: usize = 32;
49const TOUCH_WINDOW_SECONDS: i64 = 60;
51
52const KEY_COLUMNS: [&str; 7] = [
54 "prefix",
55 "secret_hash",
56 "namespace",
57 "subject",
58 "scopes",
59 "mode",
60 "created_at",
61];
62
63fn iden(name: &str) -> Alias {
64 Alias::new(name)
65}
66
67fn hex(bytes: &[u8]) -> String {
69 let mut out = String::with_capacity(bytes.len() * 2);
70 for byte in bytes {
71 let _ = write!(out, "{byte:02x}");
72 }
73 out
74}
75
76fn sha256_hex(bytes: &[u8]) -> String {
77 hex(&Sha256::digest(bytes))
78}
79
80fn stamp(at: OffsetDateTime) -> Result<String, DbError> {
83 at.format(&Rfc3339)
84 .map_err(|err| DbError::Execute(format!("clock reading is not RFC 3339: {err}")))
85}
86
87fn required(row: &Row, name: &str) -> Result<String, DbError> {
90 row.get::<Option<String>>(name)
91 .flatten()
92 .ok_or_else(|| DbError::Query(format!("api key row is missing its `{name}` column")))
93}
94
95fn is_valid_namespace(s: &str) -> bool {
98 !s.is_empty()
99 && s.len() <= MAX_NAMESPACE_CHARS
100 && s.bytes()
101 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
102}
103
104fn is_lower_hex(s: &str) -> bool {
105 s.bytes()
106 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
107}
108
109pub trait RandomBytes: Send + Sync {
123 fn fill(&self, dest: &mut [u8]) -> Result<(), RandomError>;
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
134#[error("entropy source failed: {0}")]
135pub struct RandomError(String);
136
137#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
139#[non_exhaustive]
140pub enum ApiKeyError {
141 #[error("invalid key namespace {0:?}: expected [a-z0-9]+")]
143 Namespace(String),
144 #[error("invalid scope {0:?}: empty, or containing whitespace")]
147 Scope(String),
148 #[error("no active key with prefix {0:?}")]
150 UnknownKey(String),
151 #[error(transparent)]
153 Random(#[from] RandomError),
154 #[error(transparent)]
156 Db(#[from] DbError),
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum ApiKeyMode {
162 Test,
164 Live,
166}
167
168impl ApiKeyMode {
169 #[must_use]
171 pub const fn as_str(self) -> &'static str {
172 match self {
173 Self::Test => "test",
174 Self::Live => "live",
175 }
176 }
177
178 fn parse(s: &str) -> Option<Self> {
180 match s {
181 "test" => Some(Self::Test),
182 "live" => Some(Self::Live),
183 _ => None,
184 }
185 }
186}
187
188#[derive(Clone, PartialEq, Eq)]
191pub struct IssuedKey {
192 pub token: String,
194 pub prefix: String,
196}
197
198impl std::fmt::Debug for IssuedKey {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 f.debug_struct("IssuedKey")
203 .field("prefix", &self.prefix)
204 .field("token", &"[redacted]")
205 .finish()
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct ApiKeyPrincipal {
212 pub prefix: String,
214 pub namespace: String,
216 pub subject: String,
218 pub scopes: Vec<String>,
220 pub mode: ApiKeyMode,
222}
223
224impl ApiKeyPrincipal {
225 #[must_use]
227 pub fn has_scope(&self, scope: &str) -> bool {
228 self.scopes.iter().any(|granted| granted == scope)
229 }
230
231 #[must_use]
236 pub fn rate_limit_key(&self) -> String {
237 format!("apikey:{}", self.prefix)
238 }
239
240 #[must_use]
244 pub fn to_subject(&self) -> crate::ports::Subject {
245 crate::ports::Subject::new(self.subject.clone()).session(self.prefix.clone())
246 }
247}
248
249struct ParsedKey {
252 prefix: String,
253 namespace: String,
254 mode: ApiKeyMode,
255}
256
257fn parse_prefix(prefix: &str) -> Option<(String, ApiKeyMode)> {
260 let mut parts = prefix.split('_');
261 let namespace = parts.next()?;
262 let mode = parts.next()?;
263 let id = parts.next()?;
264 if parts.next().is_some()
265 || !is_valid_namespace(namespace)
266 || id.len() != ID_BYTES * 2
267 || !is_lower_hex(id)
268 {
269 return None;
270 }
271 Some((namespace.to_owned(), ApiKeyMode::parse(mode)?))
272}
273
274impl ParsedKey {
275 fn parse(token: &str) -> Option<Self> {
278 let (prefix, secret) = token.rsplit_once('_')?;
279 if secret.len() != SECRET_BYTES * 2 || !is_lower_hex(secret) {
280 return None;
281 }
282 let (namespace, mode) = parse_prefix(prefix)?;
283 Some(Self {
284 prefix: prefix.to_owned(),
285 namespace,
286 mode,
287 })
288 }
289}
290
291struct KeyRow {
293 secret_hash: String,
294 subject: String,
295 scopes: String,
296 last_used_at: Option<String>,
297}
298
299#[derive(Clone)]
305pub struct ApiKeys {
306 db: Arc<dyn Database>,
307 clock: Arc<dyn Clock>,
308 rng: Arc<dyn RandomBytes>,
309 table: String,
310}
311
312impl ApiKeys {
313 #[must_use]
315 pub fn new(
316 db: Arc<dyn Database>,
317 clock: Arc<dyn Clock>,
318 rng: Arc<dyn RandomBytes>,
319 table: impl Into<String>,
320 ) -> Self {
321 Self {
322 db,
323 clock,
324 rng,
325 table: table.into(),
326 }
327 }
328
329 #[must_use]
333 pub fn create_table_sql(&self) -> String {
334 format!(
335 "CREATE TABLE IF NOT EXISTS {table} (\n \
336 prefix TEXT PRIMARY KEY,\n \
337 secret_hash TEXT NOT NULL,\n \
338 namespace TEXT NOT NULL,\n \
339 subject TEXT NOT NULL,\n \
340 scopes TEXT NOT NULL,\n \
341 mode TEXT NOT NULL,\n \
342 created_at TEXT NOT NULL,\n \
343 last_used_at TEXT,\n \
344 revoked_at TEXT\n);",
345 table = self.table
346 )
347 }
348
349 fn generate(&self) -> Result<(String, String), RandomError> {
352 let mut bytes = [0u8; ID_BYTES + SECRET_BYTES];
353 self.rng.fill(&mut bytes)?;
354 let (id, secret) = bytes.split_at(ID_BYTES);
355 Ok((hex(id), hex(secret)))
356 }
357
358 pub async fn issue(
367 &self,
368 namespace: &str,
369 subject: &str,
370 scopes: &[&str],
371 mode: ApiKeyMode,
372 ) -> Result<IssuedKey, ApiKeyError> {
373 if !is_valid_namespace(namespace) {
374 return Err(ApiKeyError::Namespace(namespace.to_owned()));
375 }
376 for scope in scopes {
377 if scope.is_empty() || scope.contains(char::is_whitespace) {
378 return Err(ApiKeyError::Scope((*scope).to_owned()));
379 }
380 }
381 let (id, secret) = self.generate()?;
382 let prefix = format!("{namespace}_{}_{id}", mode.as_str());
383 let token = format!("{prefix}_{secret}");
384 let now = stamp(self.clock.now())?;
385 let mut insert = Query::insert();
386 insert
387 .into_table(iden(&self.table))
388 .columns(KEY_COLUMNS)
389 .values_panic([
390 prefix.clone().into(),
391 sha256_hex(token.as_bytes()).into(),
392 namespace.to_owned().into(),
393 subject.to_owned().into(),
394 scopes.join(" ").into(),
395 mode.as_str().into(),
396 now.into(),
397 ]);
398 self.db.execute(&Statement::render(&insert)).await?;
399 Ok(IssuedKey { token, prefix })
400 }
401
402 async fn load(&self, prefix: &str) -> Result<Option<KeyRow>, DbError> {
404 let mut select = Query::select();
405 select
406 .columns([
407 "secret_hash",
408 "subject",
409 "scopes",
410 "last_used_at",
411 "revoked_at",
412 ])
413 .from(iden(&self.table))
414 .and_where(Expr::col(iden("prefix")).eq(prefix));
415 let rows = self.db.query(&Statement::render(&select)).await?;
416 let Some(row) = rows.first() else {
417 return Ok(None);
418 };
419 if let Some(Some(_)) = row.get::<Option<String>>("revoked_at") {
422 return Ok(None);
423 }
424 Ok(Some(KeyRow {
425 secret_hash: required(row, "secret_hash")?,
426 subject: required(row, "subject")?,
427 scopes: required(row, "scopes")?,
428 last_used_at: row.get::<Option<String>>("last_used_at").flatten(),
429 }))
430 }
431
432 pub async fn verify(&self, token: &str) -> Result<Option<ApiKeyPrincipal>, DbError> {
445 let Some(parsed) = ParsedKey::parse(token) else {
446 return Ok(None);
447 };
448 let Some(row) = self.load(&parsed.prefix).await? else {
449 return Ok(None);
450 };
451 let presented = sha256_hex(token.as_bytes());
452 if !constant_time_eq(presented.as_bytes(), row.secret_hash.as_bytes()) {
453 return Ok(None);
454 }
455 if let Err(err) = self
456 .touch(&parsed.prefix, row.last_used_at.as_deref())
457 .await
458 {
459 tracing::warn!(error = %err, prefix = %parsed.prefix, "api key last-used touch failed");
462 }
463 Ok(Some(ApiKeyPrincipal {
464 prefix: parsed.prefix,
465 namespace: parsed.namespace,
466 subject: row.subject,
467 scopes: row
468 .scopes
469 .split(' ')
470 .filter(|scope| !scope.is_empty())
471 .map(str::to_owned)
472 .collect(),
473 mode: parsed.mode,
474 }))
475 }
476
477 fn touch_due(last_used_at: Option<&str>, now: OffsetDateTime) -> bool {
480 let Some(stored) = last_used_at else {
481 return true;
482 };
483 match OffsetDateTime::parse(stored, &Rfc3339) {
484 Ok(then) => (now - then).whole_seconds() >= TOUCH_WINDOW_SECONDS,
485 Err(_) => true,
486 }
487 }
488
489 async fn touch(&self, prefix: &str, last_used_at: Option<&str>) -> Result<(), DbError> {
491 let now = self.clock.now();
492 if !Self::touch_due(last_used_at, now) {
493 return Ok(());
494 }
495 let mut update = Query::update();
496 update
497 .table(iden(&self.table))
498 .value(iden("last_used_at"), stamp(now)?)
499 .and_where(Expr::col(iden("prefix")).eq(prefix));
500 self.db.execute(&Statement::render(&update)).await?;
501 Ok(())
502 }
503
504 pub async fn revoke(&self, prefix: &str) -> Result<(), ApiKeyError> {
513 let mut update = Query::update();
514 update
515 .table(iden(&self.table))
516 .value(iden("revoked_at"), stamp(self.clock.now())?)
517 .and_where(Expr::col(iden("prefix")).eq(prefix));
518 if self.db.execute(&Statement::render(&update)).await? == 0 {
519 return Err(ApiKeyError::UnknownKey(prefix.to_owned()));
520 }
521 Ok(())
522 }
523
524 pub async fn rotate(&self, prefix: &str) -> Result<IssuedKey, ApiKeyError> {
538 let Some((namespace, mode)) = parse_prefix(prefix) else {
539 return Err(ApiKeyError::UnknownKey(prefix.to_owned()));
540 };
541 let (id, secret) = self.generate()?;
542 let new_prefix = format!("{namespace}_{}_{id}", mode.as_str());
543 let token = format!("{new_prefix}_{secret}");
544 let now = stamp(self.clock.now())?;
545 let mut insert = Query::insert();
547 insert
548 .into_table(iden(&self.table))
549 .columns(KEY_COLUMNS)
550 .select_from(
551 Query::select()
552 .expr(Expr::val(new_prefix.clone()))
553 .expr(Expr::val(sha256_hex(token.as_bytes())))
554 .expr(Expr::col(iden("namespace")))
555 .expr(Expr::col(iden("subject")))
556 .expr(Expr::col(iden("scopes")))
557 .expr(Expr::col(iden("mode")))
558 .expr(Expr::val(now.clone()))
559 .from(iden(&self.table))
560 .and_where(Expr::col(iden("prefix")).eq(prefix))
561 .and_where(Expr::col(iden("revoked_at")).is_null())
562 .to_owned(),
563 )
564 .map_err(|err| DbError::Execute(format!("rotate copy is malformed: {err}")))?;
565 let mut revoke_old = Query::update();
566 revoke_old
567 .table(iden(&self.table))
568 .value(iden("revoked_at"), now)
569 .and_where(Expr::col(iden("prefix")).eq(prefix))
570 .and_where(Expr::col(iden("revoked_at")).is_null());
571 self.db
572 .batch_atomic(&[Statement::render(&insert), Statement::render(&revoke_old)])
573 .await?;
574 if self.load(&new_prefix).await?.is_none() {
577 return Err(ApiKeyError::UnknownKey(prefix.to_owned()));
578 }
579 Ok(IssuedKey {
580 token,
581 prefix: new_prefix,
582 })
583 }
584}
585
586pub async fn require_api_key(
600 keys: &ApiKeys,
601 headers: &HeaderMap,
602 scope: &str,
603) -> Result<ApiKeyPrincipal, Problem> {
604 let unauthorized = || Problem::new(&SLUGS.api_key_unauthorized);
605 let Some(token) = bearer_token(headers) else {
606 return Err(unauthorized());
607 };
608 let Some(principal) = keys.verify(token).await.map_err(|err| {
609 tracing::warn!(error = %err, "api key lookup failed");
610 Problem::new(&SLUGS.not_ready).with_detail("api key store lookup failed")
611 })?
612 else {
613 return Err(unauthorized());
614 };
615 if !principal.has_scope(scope) {
616 return Err(Problem::new(&SLUGS.api_key_forbidden)
617 .with_detail(format!("the key lacks the required scope {scope:?}")));
618 }
619 Ok(principal)
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn parse_accepts_the_documented_shape() {
628 let token = "pos_live_3f9a0c1d2e4b5a6c_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
629 let parsed = ParsedKey::parse(token).expect("parses");
630 assert_eq!(parsed.prefix, "pos_live_3f9a0c1d2e4b5a6c");
631 assert_eq!(parsed.namespace, "pos");
632 assert_eq!(parsed.mode, ApiKeyMode::Live);
633 }
634
635 #[test]
636 fn parse_rejects_every_malformed_shape() {
637 let good_secret = "ab".repeat(SECRET_BYTES);
638 let good_id = "cd".repeat(ID_BYTES);
639 let malformed: Vec<String> = vec![
640 String::new(),
641 format!("pos_live_{good_id}"),
642 format!("pos_live_{good_id}_{good_secret}_extra"),
643 format!("pos_test_{good_id}_short"),
644 format!("pos_test_{good_id}_{}", "AB".repeat(SECRET_BYTES)),
645 format!("pos_live_3f9a_{good_secret}"),
646 format!("pos_sand_{good_id}_{good_secret}"),
647 format!("Pos_live_{good_id}_{good_secret}"),
648 format!("pos-api_live_{good_id}_{good_secret}"),
649 format!(
650 "{}s_live_{good_id}_{good_secret}",
651 "a".repeat(MAX_NAMESPACE_CHARS)
652 ),
653 format!("pos_live_{good_id}_{good_secret} "),
654 ];
655 for token in &malformed {
656 assert!(ParsedKey::parse(token).is_none(), "{token:?} parsed");
657 }
658 }
659}