use async_trait::async_trait;
use chrono::Utc;
use sea_orm::DatabaseConnection;
use bitrouter_sdk::caller::CallerContext;
use bitrouter_sdk::language_model::{DenyReason, HookDecision, PipelineContext, PreRequestHook};
use bitrouter_sdk::{PluginId, Result};
use crate::auth::db::{self, ApiKeyRecord};
use crate::auth::events::Authenticated;
use crate::auth::keys;
pub fn plugin_id() -> PluginId {
PluginId::new("bitrouter-auth")
}
pub struct AuthHook {
db: DatabaseConnection,
}
impl AuthHook {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
fn extract_credential(ctx: &PipelineContext) -> Option<String> {
let headers = ctx.headers();
if let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
let token = auth.strip_prefix("Bearer ").unwrap_or(auth).trim();
if !token.is_empty() {
return Some(token.to_string());
}
}
if let Some(key) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
let key = key.trim();
if !key.is_empty() {
return Some(key.to_string());
}
}
None
}
fn caller_from_record(record: &ApiKeyRecord) -> CallerContext {
CallerContext::new(&record.id, &record.user_id)
}
}
#[async_trait]
impl PreRequestHook for AuthHook {
async fn check(&self, ctx: &mut PipelineContext) -> Result<HookDecision> {
if ctx.caller().is_local() {
return Ok(HookDecision::Allow);
}
let credential = Self::extract_credential(ctx);
let Some(credential) = credential else {
return Ok(HookDecision::Deny(DenyReason::Unauthorized(
"missing API key".to_string(),
)));
};
if !keys::looks_like_virtual_key(&credential) {
return Ok(HookDecision::Deny(DenyReason::Unauthorized(
"credential is not a brvk_ virtual key".to_string(),
)));
}
let hash = keys::hash_key(&credential);
let record = db::find_key_by_hash(&self.db, &hash).await?;
let Some(record) = record else {
return Ok(HookDecision::Deny(DenyReason::Unauthorized(
"unknown API key".to_string(),
)));
};
if !record.active {
return Ok(HookDecision::Deny(DenyReason::Unauthorized(
"API key is inactive".to_string(),
)));
}
if let Some(expires_at) = record.expires_at
&& expires_at <= Utc::now()
{
return Ok(HookDecision::Deny(DenyReason::Unauthorized(
"API key has expired".to_string(),
)));
}
let caller = Self::caller_from_record(&record);
ctx.set_caller(caller);
ctx.set_metadata(
&plugin_id(),
serde_json::json!({
"api_key_id": record.id,
"user_id": record.user_id,
"policy_id": record.policy_id,
}),
);
ctx.emit(Authenticated {
api_key_id: record.id,
user_id: record.user_id,
policy_id: record.policy_id,
});
Ok(HookDecision::Allow)
}
}