use std::fmt::Write as _;
use std::sync::Arc;
use axum::http::HeaderMap;
use sea_query::{Alias, Expr, Query};
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use crate::admin::{bearer_token, constant_time_eq};
use crate::ports::{Clock, Database, DbError, Row, Statement};
use crate::problem::Problem;
use crate::problems::SLUGS;
const ID_BYTES: usize = 8;
const SECRET_BYTES: usize = 32;
const MAX_NAMESPACE_CHARS: usize = 32;
const TOUCH_WINDOW_SECONDS: i64 = 60;
const KEY_COLUMNS: [&str; 7] = [
"prefix",
"secret_hash",
"namespace",
"subject",
"scopes",
"mode",
"created_at",
];
fn iden(name: &str) -> Alias {
Alias::new(name)
}
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
fn sha256_hex(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
fn stamp(at: OffsetDateTime) -> Result<String, DbError> {
at.format(&Rfc3339)
.map_err(|err| DbError::Execute(format!("clock reading is not RFC 3339: {err}")))
}
fn required(row: &Row, name: &str) -> Result<String, DbError> {
row.get::<Option<String>>(name)
.flatten()
.ok_or_else(|| DbError::Query(format!("api key row is missing its `{name}` column")))
}
fn is_valid_namespace(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_NAMESPACE_CHARS
&& s.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
}
fn is_lower_hex(s: &str) -> bool {
s.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
pub trait RandomBytes: Send + Sync {
fn fill(&self, dest: &mut [u8]) -> Result<(), RandomError>;
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("entropy source failed: {0}")]
pub struct RandomError(String);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ApiKeyError {
#[error("invalid key namespace {0:?}: expected [a-z0-9]+")]
Namespace(String),
#[error("invalid scope {0:?}: empty, or containing whitespace")]
Scope(String),
#[error("no active key with prefix {0:?}")]
UnknownKey(String),
#[error(transparent)]
Random(#[from] RandomError),
#[error(transparent)]
Db(#[from] DbError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiKeyMode {
Test,
Live,
}
impl ApiKeyMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Test => "test",
Self::Live => "live",
}
}
fn parse(s: &str) -> Option<Self> {
match s {
"test" => Some(Self::Test),
"live" => Some(Self::Live),
_ => None,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct IssuedKey {
pub token: String,
pub prefix: String,
}
impl std::fmt::Debug for IssuedKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IssuedKey")
.field("prefix", &self.prefix)
.field("token", &"[redacted]")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiKeyPrincipal {
pub prefix: String,
pub namespace: String,
pub subject: String,
pub scopes: Vec<String>,
pub mode: ApiKeyMode,
}
impl ApiKeyPrincipal {
#[must_use]
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|granted| granted == scope)
}
#[must_use]
pub fn rate_limit_key(&self) -> String {
format!("apikey:{}", self.prefix)
}
#[must_use]
pub fn to_subject(&self) -> crate::ports::Subject {
crate::ports::Subject::new(self.subject.clone()).session(self.prefix.clone())
}
}
struct ParsedKey {
prefix: String,
namespace: String,
mode: ApiKeyMode,
}
fn parse_prefix(prefix: &str) -> Option<(String, ApiKeyMode)> {
let mut parts = prefix.split('_');
let namespace = parts.next()?;
let mode = parts.next()?;
let id = parts.next()?;
if parts.next().is_some()
|| !is_valid_namespace(namespace)
|| id.len() != ID_BYTES * 2
|| !is_lower_hex(id)
{
return None;
}
Some((namespace.to_owned(), ApiKeyMode::parse(mode)?))
}
impl ParsedKey {
fn parse(token: &str) -> Option<Self> {
let (prefix, secret) = token.rsplit_once('_')?;
if secret.len() != SECRET_BYTES * 2 || !is_lower_hex(secret) {
return None;
}
let (namespace, mode) = parse_prefix(prefix)?;
Some(Self {
prefix: prefix.to_owned(),
namespace,
mode,
})
}
}
struct KeyRow {
secret_hash: String,
subject: String,
scopes: String,
last_used_at: Option<String>,
}
#[derive(Clone)]
pub struct ApiKeys {
db: Arc<dyn Database>,
clock: Arc<dyn Clock>,
rng: Arc<dyn RandomBytes>,
table: String,
}
impl ApiKeys {
#[must_use]
pub fn new(
db: Arc<dyn Database>,
clock: Arc<dyn Clock>,
rng: Arc<dyn RandomBytes>,
table: impl Into<String>,
) -> Self {
Self {
db,
clock,
rng,
table: table.into(),
}
}
#[must_use]
pub fn create_table_sql(&self) -> String {
format!(
"CREATE TABLE IF NOT EXISTS {table} (\n \
prefix TEXT PRIMARY KEY,\n \
secret_hash TEXT NOT NULL,\n \
namespace TEXT NOT NULL,\n \
subject TEXT NOT NULL,\n \
scopes TEXT NOT NULL,\n \
mode TEXT NOT NULL,\n \
created_at TEXT NOT NULL,\n \
last_used_at TEXT,\n \
revoked_at TEXT\n);",
table = self.table
)
}
fn generate(&self) -> Result<(String, String), RandomError> {
let mut bytes = [0u8; ID_BYTES + SECRET_BYTES];
self.rng.fill(&mut bytes)?;
let (id, secret) = bytes.split_at(ID_BYTES);
Ok((hex(id), hex(secret)))
}
pub async fn issue(
&self,
namespace: &str,
subject: &str,
scopes: &[&str],
mode: ApiKeyMode,
) -> Result<IssuedKey, ApiKeyError> {
if !is_valid_namespace(namespace) {
return Err(ApiKeyError::Namespace(namespace.to_owned()));
}
for scope in scopes {
if scope.is_empty() || scope.contains(char::is_whitespace) {
return Err(ApiKeyError::Scope((*scope).to_owned()));
}
}
let (id, secret) = self.generate()?;
let prefix = format!("{namespace}_{}_{id}", mode.as_str());
let token = format!("{prefix}_{secret}");
let now = stamp(self.clock.now())?;
let mut insert = Query::insert();
insert
.into_table(iden(&self.table))
.columns(KEY_COLUMNS)
.values_panic([
prefix.clone().into(),
sha256_hex(token.as_bytes()).into(),
namespace.to_owned().into(),
subject.to_owned().into(),
scopes.join(" ").into(),
mode.as_str().into(),
now.into(),
]);
self.db.execute(&Statement::render(&insert)).await?;
Ok(IssuedKey { token, prefix })
}
async fn load(&self, prefix: &str) -> Result<Option<KeyRow>, DbError> {
let mut select = Query::select();
select
.columns([
"secret_hash",
"subject",
"scopes",
"last_used_at",
"revoked_at",
])
.from(iden(&self.table))
.and_where(Expr::col(iden("prefix")).eq(prefix));
let rows = self.db.query(&Statement::render(&select)).await?;
let Some(row) = rows.first() else {
return Ok(None);
};
if let Some(Some(_)) = row.get::<Option<String>>("revoked_at") {
return Ok(None);
}
Ok(Some(KeyRow {
secret_hash: required(row, "secret_hash")?,
subject: required(row, "subject")?,
scopes: required(row, "scopes")?,
last_used_at: row.get::<Option<String>>("last_used_at").flatten(),
}))
}
pub async fn verify(&self, token: &str) -> Result<Option<ApiKeyPrincipal>, DbError> {
let Some(parsed) = ParsedKey::parse(token) else {
return Ok(None);
};
let Some(row) = self.load(&parsed.prefix).await? else {
return Ok(None);
};
let presented = sha256_hex(token.as_bytes());
if !constant_time_eq(presented.as_bytes(), row.secret_hash.as_bytes()) {
return Ok(None);
}
if let Err(err) = self
.touch(&parsed.prefix, row.last_used_at.as_deref())
.await
{
tracing::warn!(error = %err, prefix = %parsed.prefix, "api key last-used touch failed");
}
Ok(Some(ApiKeyPrincipal {
prefix: parsed.prefix,
namespace: parsed.namespace,
subject: row.subject,
scopes: row
.scopes
.split(' ')
.filter(|scope| !scope.is_empty())
.map(str::to_owned)
.collect(),
mode: parsed.mode,
}))
}
fn touch_due(last_used_at: Option<&str>, now: OffsetDateTime) -> bool {
let Some(stored) = last_used_at else {
return true;
};
match OffsetDateTime::parse(stored, &Rfc3339) {
Ok(then) => (now - then).whole_seconds() >= TOUCH_WINDOW_SECONDS,
Err(_) => true,
}
}
async fn touch(&self, prefix: &str, last_used_at: Option<&str>) -> Result<(), DbError> {
let now = self.clock.now();
if !Self::touch_due(last_used_at, now) {
return Ok(());
}
let mut update = Query::update();
update
.table(iden(&self.table))
.value(iden("last_used_at"), stamp(now)?)
.and_where(Expr::col(iden("prefix")).eq(prefix));
self.db.execute(&Statement::render(&update)).await?;
Ok(())
}
pub async fn revoke(&self, prefix: &str) -> Result<(), ApiKeyError> {
let mut update = Query::update();
update
.table(iden(&self.table))
.value(iden("revoked_at"), stamp(self.clock.now())?)
.and_where(Expr::col(iden("prefix")).eq(prefix));
if self.db.execute(&Statement::render(&update)).await? == 0 {
return Err(ApiKeyError::UnknownKey(prefix.to_owned()));
}
Ok(())
}
pub async fn rotate(&self, prefix: &str) -> Result<IssuedKey, ApiKeyError> {
let Some((namespace, mode)) = parse_prefix(prefix) else {
return Err(ApiKeyError::UnknownKey(prefix.to_owned()));
};
let (id, secret) = self.generate()?;
let new_prefix = format!("{namespace}_{}_{id}", mode.as_str());
let token = format!("{new_prefix}_{secret}");
let now = stamp(self.clock.now())?;
let mut insert = Query::insert();
insert
.into_table(iden(&self.table))
.columns(KEY_COLUMNS)
.select_from(
Query::select()
.expr(Expr::val(new_prefix.clone()))
.expr(Expr::val(sha256_hex(token.as_bytes())))
.expr(Expr::col(iden("namespace")))
.expr(Expr::col(iden("subject")))
.expr(Expr::col(iden("scopes")))
.expr(Expr::col(iden("mode")))
.expr(Expr::val(now.clone()))
.from(iden(&self.table))
.and_where(Expr::col(iden("prefix")).eq(prefix))
.and_where(Expr::col(iden("revoked_at")).is_null())
.to_owned(),
)
.map_err(|err| DbError::Execute(format!("rotate copy is malformed: {err}")))?;
let mut revoke_old = Query::update();
revoke_old
.table(iden(&self.table))
.value(iden("revoked_at"), now)
.and_where(Expr::col(iden("prefix")).eq(prefix))
.and_where(Expr::col(iden("revoked_at")).is_null());
self.db
.batch_atomic(&[Statement::render(&insert), Statement::render(&revoke_old)])
.await?;
if self.load(&new_prefix).await?.is_none() {
return Err(ApiKeyError::UnknownKey(prefix.to_owned()));
}
Ok(IssuedKey {
token,
prefix: new_prefix,
})
}
}
pub async fn require_api_key(
keys: &ApiKeys,
headers: &HeaderMap,
scope: &str,
) -> Result<ApiKeyPrincipal, Problem> {
let unauthorized = || Problem::new(&SLUGS.api_key_unauthorized);
let Some(token) = bearer_token(headers) else {
return Err(unauthorized());
};
let Some(principal) = keys.verify(token).await.map_err(|err| {
tracing::warn!(error = %err, "api key lookup failed");
Problem::new(&SLUGS.not_ready).with_detail("api key store lookup failed")
})?
else {
return Err(unauthorized());
};
if !principal.has_scope(scope) {
return Err(Problem::new(&SLUGS.api_key_forbidden)
.with_detail(format!("the key lacks the required scope {scope:?}")));
}
Ok(principal)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_the_documented_shape() {
let token = "pos_live_3f9a0c1d2e4b5a6c_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let parsed = ParsedKey::parse(token).expect("parses");
assert_eq!(parsed.prefix, "pos_live_3f9a0c1d2e4b5a6c");
assert_eq!(parsed.namespace, "pos");
assert_eq!(parsed.mode, ApiKeyMode::Live);
}
#[test]
fn parse_rejects_every_malformed_shape() {
let good_secret = "ab".repeat(SECRET_BYTES);
let good_id = "cd".repeat(ID_BYTES);
let malformed: Vec<String> = vec![
String::new(),
format!("pos_live_{good_id}"),
format!("pos_live_{good_id}_{good_secret}_extra"),
format!("pos_test_{good_id}_short"),
format!("pos_test_{good_id}_{}", "AB".repeat(SECRET_BYTES)),
format!("pos_live_3f9a_{good_secret}"),
format!("pos_sand_{good_id}_{good_secret}"),
format!("Pos_live_{good_id}_{good_secret}"),
format!("pos-api_live_{good_id}_{good_secret}"),
format!(
"{}s_live_{good_id}_{good_secret}",
"a".repeat(MAX_NAMESPACE_CHARS)
),
format!("pos_live_{good_id}_{good_secret} "),
];
for token in &malformed {
assert!(ParsedKey::parse(token).is_none(), "{token:?} parsed");
}
}
}