use chrono::{DateTime, Utc};
use rand::RngCore;
use sqlx::{PgPool, Row};
use super::sha256_hash;
pub const PG_API_KEY_SCHEMA_SQL: &str = r"
CREATE SCHEMA IF NOT EXISTS core;
CREATE TABLE IF NOT EXISTS core.tb_api_key (
pk_api_key UUID PRIMARY KEY DEFAULT gen_random_uuid(),
selector TEXT NOT NULL UNIQUE,
verifier_hash BYTEA NOT NULL,
name TEXT NOT NULL,
scopes TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ
);
";
const KEY_PREFIX: &str = "fqlk";
const SELECTOR_BYTES: usize = 12;
const VERIFIER_BYTES: usize = 24;
#[derive(Debug)]
#[non_exhaustive]
pub enum ApiKeyStoreError {
InvalidInput(String),
NotFound,
Database(String),
}
impl std::fmt::Display for ApiKeyStoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidInput(msg) => write!(f, "{msg}"),
Self::NotFound => write!(f, "API key not found"),
Self::Database(msg) => write!(f, "Database error: {msg}"),
}
}
}
impl std::error::Error for ApiKeyStoreError {}
impl From<sqlx::Error> for ApiKeyStoreError {
fn from(e: sqlx::Error) -> Self {
Self::Database(e.to_string())
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ApiKeyRecord {
pub selector: String,
pub name: String,
pub scopes: Vec<String>,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
}
pub(super) struct ResolvedDbKey {
pub verifier_hash: Vec<u8>,
pub name: String,
pub scopes: Vec<String>,
pub expires_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}
pub(super) fn parse_key(raw: &str) -> Option<(&str, &str)> {
let rest = raw.strip_prefix(KEY_PREFIX)?.strip_prefix('_')?;
let (selector, verifier) = rest.split_once('_')?;
if selector.len() == SELECTOR_BYTES * 2 && verifier.len() == VERIFIER_BYTES * 2 {
Some((selector, verifier))
} else {
None
}
}
#[derive(Debug, Clone)]
pub struct PgApiKeyStore {
pool: PgPool,
}
impl PgApiKeyStore {
#[must_use]
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn ensure_schema(&self) -> Result<(), ApiKeyStoreError> {
for stmt in PG_API_KEY_SCHEMA_SQL.split(';').map(str::trim).filter(|s| !s.is_empty()) {
sqlx::query(stmt).execute(&self.pool).await?;
}
Ok(())
}
pub async fn create_key(
&self,
name: &str,
scopes: &[String],
expires_at: Option<DateTime<Utc>>,
) -> Result<(String, ApiKeyRecord), ApiKeyStoreError> {
if name.trim().is_empty() {
return Err(ApiKeyStoreError::InvalidInput("key name must not be empty".into()));
}
let (selector, verifier, full_key) = generate_key_material();
let verifier_hash = sha256_hash(verifier.as_bytes()).to_vec();
let row = sqlx::query(
"INSERT INTO core.tb_api_key (selector, verifier_hash, name, scopes, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at",
)
.bind(&selector)
.bind(&verifier_hash)
.bind(name)
.bind(scopes)
.bind(expires_at)
.fetch_one(&self.pool)
.await?;
let record = ApiKeyRecord {
selector,
name: name.to_string(),
scopes: scopes.to_vec(),
created_at: row.get("created_at"),
expires_at,
revoked_at: None,
last_used_at: None,
};
Ok((full_key, record))
}
pub async fn list_keys(&self) -> Result<Vec<ApiKeyRecord>, ApiKeyStoreError> {
let rows = sqlx::query(
"SELECT selector, name, scopes, created_at, expires_at, revoked_at, last_used_at
FROM core.tb_api_key ORDER BY created_at DESC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| ApiKeyRecord {
selector: r.get("selector"),
name: r.get("name"),
scopes: r.get("scopes"),
created_at: r.get("created_at"),
expires_at: r.get("expires_at"),
revoked_at: r.get("revoked_at"),
last_used_at: r.get("last_used_at"),
})
.collect())
}
pub async fn revoke(&self, selector: &str) -> Result<(), ApiKeyStoreError> {
let result = sqlx::query(
"UPDATE core.tb_api_key SET revoked_at = COALESCE(revoked_at, now())
WHERE selector = $1",
)
.bind(selector)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiKeyStoreError::NotFound);
}
Ok(())
}
pub async fn rotate(&self, selector: &str) -> Result<String, ApiKeyStoreError> {
let (_, verifier, _) = generate_key_material();
let verifier_hash = sha256_hash(verifier.as_bytes()).to_vec();
let result = sqlx::query(
"UPDATE core.tb_api_key SET verifier_hash = $2
WHERE selector = $1 AND revoked_at IS NULL",
)
.bind(selector)
.bind(&verifier_hash)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiKeyStoreError::NotFound);
}
Ok(assemble_full_key(selector, &verifier))
}
pub(super) async fn resolve(
&self,
selector: &str,
) -> Result<Option<ResolvedDbKey>, ApiKeyStoreError> {
let row = sqlx::query(
"SELECT verifier_hash, name, scopes, expires_at, revoked_at
FROM core.tb_api_key WHERE selector = $1",
)
.bind(selector)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| ResolvedDbKey {
verifier_hash: r.get("verifier_hash"),
name: r.get("name"),
scopes: r.get("scopes"),
expires_at: r.get("expires_at"),
revoked_at: r.get("revoked_at"),
}))
}
pub(super) async fn touch(&self, selector: &str) -> Result<(), ApiKeyStoreError> {
sqlx::query("UPDATE core.tb_api_key SET last_used_at = now() WHERE selector = $1")
.bind(selector)
.execute(&self.pool)
.await?;
Ok(())
}
}
fn generate_key_material() -> (String, String, String) {
let mut selector_bytes = [0u8; SELECTOR_BYTES];
let mut verifier_bytes = [0u8; VERIFIER_BYTES];
rand::rng().fill_bytes(&mut selector_bytes);
rand::rng().fill_bytes(&mut verifier_bytes);
let selector = hex::encode(selector_bytes);
let verifier = hex::encode(verifier_bytes);
let full = assemble_full_key(&selector, &verifier);
(selector, verifier, full)
}
fn assemble_full_key(selector: &str, verifier: &str) -> String {
format!("{KEY_PREFIX}_{selector}_{verifier}")
}