use std::sync::Arc;
use chrono::Utc;
use crate::db::Database;
use crate::error::{Error, Result};
use crate::id;
use super::backend::ApiKeyBackend;
use super::config::ApiKeyConfig;
use super::sqlite::SqliteBackend;
use super::token;
use super::types::{ApiKeyCreated, ApiKeyMeta, ApiKeyRecord, CreateKeyRequest};
fn now_utc() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
struct Inner {
backend: Arc<dyn ApiKeyBackend>,
config: ApiKeyConfig,
}
pub struct ApiKeyStore(Arc<Inner>);
impl Clone for ApiKeyStore {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
impl ApiKeyStore {
pub fn new(db: Database, config: ApiKeyConfig) -> Result<Self> {
config.validate()?;
Ok(Self(Arc::new(Inner {
backend: Arc::new(SqliteBackend::new(db)),
config,
})))
}
pub fn from_backend(backend: Arc<dyn ApiKeyBackend>, config: ApiKeyConfig) -> Result<Self> {
config.validate()?;
Ok(Self(Arc::new(Inner { backend, config })))
}
pub async fn create(&self, req: &CreateKeyRequest) -> Result<ApiKeyCreated> {
if req.tenant_id.is_empty() {
return Err(Error::bad_request("tenant_id is required"));
}
if req.name.is_empty() {
return Err(Error::bad_request("name is required"));
}
if let Some(ref exp) = req.expires_at {
chrono::DateTime::parse_from_rfc3339(exp)
.map_err(|_| Error::bad_request("expires_at must be a valid RFC 3339 timestamp"))?;
}
let ulid = id::ulid();
let secret = token::generate_secret(self.0.config.secret_length);
let raw_token = token::format_token(&self.0.config.prefix, &ulid, &secret);
let key_hash = token::hash_secret(&secret);
let now = now_utc();
let record = ApiKeyRecord {
id: ulid.clone(),
key_hash,
tenant_id: req.tenant_id.clone(),
name: req.name.clone(),
scopes: req.scopes.clone(),
expires_at: req.expires_at.clone(),
last_used_at: None,
created_at: now.clone(),
revoked_at: None,
};
self.0.backend.store(&record).await?;
Ok(ApiKeyCreated {
id: ulid,
raw_token,
name: req.name.clone(),
scopes: req.scopes.clone(),
tenant_id: req.tenant_id.clone(),
expires_at: req.expires_at.clone(),
created_at: now,
})
}
pub async fn verify(&self, raw_token: &str) -> Result<ApiKeyMeta> {
let parsed = token::parse_token(raw_token, &self.0.config.prefix)
.ok_or_else(|| Error::unauthorized("invalid API key"))?;
let record = self
.0
.backend
.lookup(parsed.id)
.await?
.ok_or_else(|| Error::unauthorized("invalid API key"))?;
if record.revoked_at.is_some() {
return Err(Error::unauthorized("invalid API key"));
}
if let Some(ref exp) = record.expires_at {
if let Ok(exp_dt) = chrono::DateTime::parse_from_rfc3339(exp) {
if exp_dt <= Utc::now() {
return Err(Error::unauthorized("invalid API key"));
}
} else {
return Err(Error::unauthorized("invalid API key"));
}
}
if !token::verify_hash(parsed.secret, &record.key_hash) {
return Err(Error::unauthorized("invalid API key"));
}
self.maybe_touch(&record);
Ok(record.into_meta())
}
pub async fn revoke(&self, key_id: &str) -> Result<()> {
self.0
.backend
.lookup(key_id)
.await?
.ok_or_else(|| Error::not_found("API key not found"))?;
self.0.backend.revoke(key_id, &now_utc()).await
}
pub async fn list(&self, tenant_id: &str) -> Result<Vec<ApiKeyMeta>> {
let records = self.0.backend.list(tenant_id).await?;
Ok(records.into_iter().map(ApiKeyRecord::into_meta).collect())
}
pub async fn refresh(&self, key_id: &str, expires_at: Option<&str>) -> Result<()> {
if let Some(exp) = expires_at {
chrono::DateTime::parse_from_rfc3339(exp)
.map_err(|_| Error::bad_request("expires_at must be a valid RFC 3339 timestamp"))?;
}
self.0
.backend
.lookup(key_id)
.await?
.ok_or_else(|| Error::not_found("API key not found"))?;
self.0.backend.update_expires_at(key_id, expires_at).await
}
fn maybe_touch(&self, record: &ApiKeyRecord) {
let threshold_secs = self.0.config.touch_threshold_secs;
let should_touch = match &record.last_used_at {
None => true,
Some(last) => match chrono::DateTime::parse_from_rfc3339(last) {
Ok(last_dt) => {
let elapsed = chrono::Utc::now()
.signed_duration_since(last_dt)
.num_seconds();
elapsed >= threshold_secs as i64
}
Err(_) => true,
},
};
if should_touch {
let backend = self.0.backend.clone();
let key_id = record.id.clone();
tokio::spawn(async move {
if let Err(e) = backend.update_last_used(&key_id, &now_utc()).await {
tracing::warn!(key_id, error = %e, "failed to update API key last_used_at");
}
});
}
}
}