Skip to main content

bitrouter_runtime/
auth.rs

1//! Authentication filters for the bitrouter gateway.
2//!
3//! Implements a LiteLLM-style key model:
4//!
5//! - A **master key** (configured in `bitrouter.yaml`) grants [`Scope::Admin`]
6//!   access — it can call API endpoints and manage accounts/keys.
7//! - **Virtual keys** (created via the `/key/generate` endpoint using the master
8//!   key) grant [`Scope::Api`] access — they can call API endpoints only.
9//!
10//! Credentials are extracted from the protocol-appropriate header:
11//!
12//! | Protocol   | Header                          |
13//! |------------|---------------------------------|
14//! | OpenAI     | `Authorization: Bearer <key>`   |
15//! | Anthropic  | `x-api-key: <key>`              |
16//! | Management | `Authorization: Bearer <key>`   |
17//!
18//! When no `master_key` is configured, auth is disabled and all requests are
19//! allowed through (open proxy mode).
20
21use std::sync::Arc;
22
23use sea_orm::DatabaseConnection;
24use sha2::{Digest, Sha256};
25use warp::Filter;
26
27use bitrouter_accounts::identity::{AccountId, Identity, Scope};
28use bitrouter_accounts::service::AccountService;
29
30/// Shared auth state passed into filters.
31#[derive(Clone)]
32pub struct AuthContext {
33    /// The configured master key (SHA-256 hash), if any.
34    master_key_hash: Option<String>,
35    /// Database connection for virtual key lookups.
36    db: Option<DatabaseConnection>,
37}
38
39impl AuthContext {
40    pub fn new(master_key: Option<&str>, db: Option<DatabaseConnection>) -> Self {
41        Self {
42            master_key_hash: master_key.map(hash_key),
43            db,
44        }
45    }
46
47    /// Returns `true` when no master key is configured (open proxy mode).
48    pub fn is_open(&self) -> bool {
49        self.master_key_hash.is_none()
50    }
51}
52
53/// SHA-256 hash a key string, returning hex-encoded digest.
54pub fn hash_key(key: &str) -> String {
55    let mut hasher = Sha256::new();
56    hasher.update(key.as_bytes());
57    hex::encode(hasher.finalize())
58}
59
60// ── credential extraction ─────────────────────────────────────
61
62/// Extract a bearer token from `Authorization: Bearer <token>`.
63fn extract_bearer(header: &str) -> Option<&str> {
64    header
65        .strip_prefix("Bearer ")
66        .or_else(|| header.strip_prefix("bearer "))
67}
68
69/// Warp filter: extract credential from `Authorization: Bearer` header.
70pub fn bearer_credential() -> impl Filter<Extract = (String,), Error = warp::Rejection> + Clone {
71    warp::header::optional::<String>("authorization").and_then(
72        |header: Option<String>| async move {
73            match header.and_then(|h| extract_bearer(&h).map(str::to_owned)) {
74                Some(key) => Ok(key),
75                None => Err(warp::reject::custom(Unauthorized("missing bearer token"))),
76            }
77        },
78    )
79}
80
81/// Warp filter: extract credential from `x-api-key` header.
82pub fn x_api_key_credential() -> impl Filter<Extract = (String,), Error = warp::Rejection> + Clone {
83    warp::header::optional::<String>("x-api-key").and_then(|header: Option<String>| async move {
84        match header {
85            Some(key) if !key.is_empty() => Ok(key),
86            _ => Err(warp::reject::custom(Unauthorized("missing x-api-key"))),
87        }
88    })
89}
90
91/// Warp filter: extract credential from either `Authorization: Bearer` **or**
92/// `x-api-key` (Anthropic-style). Bearer takes precedence.
93pub fn any_credential() -> impl Filter<Extract = (String,), Error = warp::Rejection> + Clone {
94    warp::header::optional::<String>("authorization")
95        .and(warp::header::optional::<String>("x-api-key"))
96        .and_then(
97            |auth_header: Option<String>, x_api_key: Option<String>| async move {
98                if let Some(key) = auth_header.and_then(|h| extract_bearer(&h).map(str::to_owned)) {
99                    return Ok(key);
100                }
101                if let Some(key) = x_api_key.filter(|k| !k.is_empty()) {
102                    return Ok(key);
103                }
104                Err(warp::reject::custom(Unauthorized(
105                    "missing authentication credentials",
106                )))
107            },
108        )
109}
110
111// ── identity resolution ───────────────────────────────────────
112
113/// Resolve a credential string to an [`Identity`].
114///
115/// 1. If the credential matches the master key → Admin identity.
116/// 2. Otherwise, look up the hash in the accounts DB → Api identity.
117/// 3. If neither matches → reject.
118async fn resolve_identity(
119    credential: &str,
120    ctx: &AuthContext,
121) -> Result<Identity, warp::Rejection> {
122    let credential_hash = hash_key(credential);
123
124    // Check master key.
125    if let Some(ref master_hash) = ctx.master_key_hash
126        && constant_time_eq(&credential_hash, master_hash)
127    {
128        return Ok(Identity {
129            account_id: AccountId::new(),
130            scope: Scope::Admin,
131        });
132    }
133
134    // Check virtual key in DB.
135    if let Some(ref db) = ctx.db {
136        let svc = AccountService::new(db);
137        if let Ok(Some((account_id, _key))) = svc.resolve_api_key(&credential_hash).await {
138            return Ok(Identity {
139                account_id,
140                scope: Scope::Api,
141            });
142        }
143    }
144
145    Err(warp::reject::custom(Unauthorized("invalid API key")))
146}
147
148/// Constant-time string comparison to prevent timing attacks.
149fn constant_time_eq(a: &str, b: &str) -> bool {
150    if a.len() != b.len() {
151        return false;
152    }
153    a.bytes()
154        .zip(b.bytes())
155        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
156        == 0
157}
158
159// ── composite auth filters ────────────────────────────────────
160
161/// Build an auth filter for OpenAI-protocol routes (`Authorization: Bearer`).
162///
163/// When auth is disabled (no master key), returns a passthrough identity.
164pub fn openai_auth(
165    ctx: Arc<AuthContext>,
166) -> impl Filter<Extract = (Identity,), Error = warp::Rejection> + Clone {
167    if ctx.is_open() {
168        return open_identity().boxed();
169    }
170    let ctx = ctx.clone();
171    bearer_credential()
172        .and(warp::any().map(move || ctx.clone()))
173        .and_then(|credential: String, ctx: Arc<AuthContext>| async move {
174            resolve_identity(&credential, &ctx).await
175        })
176        .boxed()
177}
178
179/// Build an auth filter for Anthropic-protocol routes (`x-api-key`).
180///
181/// When auth is disabled (no master key), returns a passthrough identity.
182pub fn anthropic_auth(
183    ctx: Arc<AuthContext>,
184) -> impl Filter<Extract = (Identity,), Error = warp::Rejection> + Clone {
185    if ctx.is_open() {
186        return open_identity().boxed();
187    }
188    let ctx = ctx.clone();
189    x_api_key_credential()
190        .and(warp::any().map(move || ctx.clone()))
191        .and_then(|credential: String, ctx: Arc<AuthContext>| async move {
192            resolve_identity(&credential, &ctx).await
193        })
194        .boxed()
195}
196
197/// Build an auth filter for management routes. Accepts both Bearer and x-api-key.
198///
199/// When auth is disabled (no master key), returns a passthrough identity.
200pub fn management_auth(
201    ctx: Arc<AuthContext>,
202) -> impl Filter<Extract = (Identity,), Error = warp::Rejection> + Clone {
203    if ctx.is_open() {
204        return open_identity().boxed();
205    }
206    let ctx = ctx.clone();
207    any_credential()
208        .and(warp::any().map(move || ctx.clone()))
209        .and_then(|credential: String, ctx: Arc<AuthContext>| async move {
210            resolve_identity(&credential, &ctx).await
211        })
212        .boxed()
213}
214
215/// Passthrough filter when auth is disabled — produces an anonymous admin identity.
216fn open_identity() -> impl Filter<Extract = (Identity,), Error = warp::Rejection> + Clone {
217    warp::any().and_then(|| async {
218        Ok::<_, warp::Rejection>(Identity {
219            account_id: AccountId::new(),
220            scope: Scope::Admin,
221        })
222    })
223}
224
225// ── rejection types ───────────────────────────────────────────
226
227#[derive(Debug)]
228pub struct Unauthorized(pub &'static str);
229
230impl std::fmt::Display for Unauthorized {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        write!(f, "unauthorized: {}", self.0)
233    }
234}
235
236impl warp::reject::Reject for Unauthorized {}