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::mcp::{McpContext, PreRequestHook as McpPreRequestHook};
use bitrouter_sdk::{PluginId, Result};
use http::HeaderMap;
use crate::auth::db::{self, ApiKeyRecord};
use crate::auth::events::{ApiPrincipalEstablished, Authenticated};
use crate::auth::keys;
pub fn plugin_id() -> PluginId {
PluginId::new("bitrouter-auth")
}
pub struct AuthHook {
db: DatabaseConnection,
}
enum Authentication {
Local,
Authenticated {
caller: CallerContext,
record: ApiKeyRecord,
route_scope_id: String,
},
Denied(String),
}
impl AuthHook {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
fn caller_from_record(record: &ApiKeyRecord) -> CallerContext {
CallerContext::new(&record.id, &record.user_id)
}
async fn authenticate(
&self,
headers: &HeaderMap,
caller: &CallerContext,
) -> Result<Authentication> {
if caller.is_local() {
return Ok(Authentication::Local);
}
let Some(credential) = credential_from_headers(headers) else {
return Ok(Authentication::Denied("missing API key".to_string()));
};
if !keys::looks_like_virtual_key(&credential) {
return Ok(Authentication::Denied(
"credential is not a brvk_ virtual key".to_string(),
));
}
let route_scope_id = keys::hash_key(&credential);
let record = db::find_key_by_hash(&self.db, &route_scope_id).await?;
let Some(record) = record else {
return Ok(Authentication::Denied("unknown API key".to_string()));
};
if !record.active {
return Ok(Authentication::Denied("API key is inactive".to_string()));
}
if let Some(expires_at) = record.expires_at
&& expires_at <= Utc::now()
{
return Ok(Authentication::Denied("API key has expired".to_string()));
}
Ok(Authentication::Authenticated {
caller: Self::caller_from_record(&record),
record,
route_scope_id,
})
}
}
pub(crate) fn credential_from_headers(headers: &HeaderMap) -> Option<String> {
if let Some(auth) = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
{
let token = auth.strip_prefix("Bearer ").unwrap_or(auth).trim();
if !token.is_empty() {
return Some(token.to_string());
}
}
headers
.get("x-api-key")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
#[async_trait]
impl PreRequestHook for AuthHook {
async fn check(&self, ctx: &mut PipelineContext) -> Result<HookDecision> {
match self.authenticate(ctx.headers(), ctx.caller()).await? {
Authentication::Local => Ok(HookDecision::Allow),
Authentication::Denied(message) => {
Ok(HookDecision::Deny(DenyReason::Unauthorized(message)))
}
Authentication::Authenticated {
caller,
record,
route_scope_id,
} => {
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,
});
ctx.emit(ApiPrincipalEstablished { route_scope_id });
Ok(HookDecision::Allow)
}
}
}
}
#[async_trait]
impl McpPreRequestHook for AuthHook {
async fn check(&self, ctx: &mut McpContext) -> Result<HookDecision> {
match self.authenticate(ctx.headers(), ctx.caller()).await? {
Authentication::Local => Ok(HookDecision::Allow),
Authentication::Denied(message) => {
Ok(HookDecision::Deny(DenyReason::Unauthorized(message)))
}
Authentication::Authenticated {
caller,
record,
route_scope_id,
} => {
ctx.set_caller(caller);
ctx.emit(Authenticated {
api_key_id: record.id,
user_id: record.user_id,
policy_id: record.policy_id,
});
ctx.emit(ApiPrincipalEstablished { route_scope_id });
Ok(HookDecision::Allow)
}
}
}
}