use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use axum::{
body::Body,
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AuthConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub api_keys: HashMap<String, ApiKeyEntry>,
#[serde(default)]
pub jwt: Option<JwtConfig>,
#[serde(default = "default_bypass")]
pub bypass_paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiKeyEntry {
pub label: String,
#[serde(default)]
pub scopes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JwtConfig {
pub algorithms: Vec<String>,
pub jwks_url: Option<String>,
pub keys: Option<Vec<String>>,
pub issuer: Option<String>,
pub audience: Option<String>,
#[serde(default = "default_true")]
pub validate_expiry: bool,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
enabled: false,
api_keys: HashMap::new(),
jwt: None,
bypass_paths: default_bypass(),
}
}
}
fn default_bypass() -> Vec<String> {
vec![
"/healthz".into(),
"/livez".into(),
"/readyz".into(),
"/metrics".into(),
"/.well-known/agent.json".into(),
]
}
fn default_true() -> bool {
true
}
#[derive(Clone)]
pub struct AuthState {
inner: Arc<AuthInner>,
}
struct AuthInner {
config: AuthConfig,
jwk_map: HashMap<String, DecodingKey>,
jwt_validation: Option<Validation>,
_algorithms: Vec<Algorithm>,
failure_count: AtomicU64,
}
impl AuthState {
pub fn new(config: AuthConfig) -> Self {
let algorithms: Vec<Algorithm> = match &config.jwt {
Some(jwt) => jwt
.algorithms
.iter()
.filter_map(|a| match a.as_str() {
"RS256" => Some(Algorithm::RS256),
"ES256" => Some(Algorithm::ES256),
"HS256" => Some(Algorithm::HS256),
"RS384" => Some(Algorithm::RS384),
"RS512" => Some(Algorithm::RS512),
_ => None,
})
.collect(),
None => vec![],
};
let mut jwk_map = HashMap::new();
if let Some(jwt) = &config.jwt {
if let Some(keys) = &jwt.keys {
for (i, pem) in keys.iter().enumerate() {
let key = DecodingKey::from_rsa_pem(pem.as_bytes())
.or_else(|_| DecodingKey::from_ec_pem(pem.as_bytes()))
.ok()
.or_else(|| Some(DecodingKey::from_secret(pem.as_bytes())));
if let Some(k) = key {
jwk_map.insert(format!("static-{}", i), k);
}
}
}
}
let jwt_validation = config.jwt.as_ref().map(|jwt| {
let mut v = Validation::new(algorithms.first().copied().unwrap_or(Algorithm::RS256));
if let Some(iss) = &jwt.issuer {
v.set_issuer(&[iss]);
}
if let Some(aud) = &jwt.audience {
v.set_audience(&[aud]);
}
v.validate_exp = jwt.validate_expiry;
v.validate_nbf = jwt.validate_expiry;
v.algorithms = algorithms.clone();
v
});
Self {
inner: Arc::new(AuthInner {
config,
jwk_map,
jwt_validation,
_algorithms: algorithms,
failure_count: AtomicU64::new(0),
}),
}
}
pub fn authenticate(&self, req: &Request<Body>) -> Option<String> {
let inner = &self.inner;
let auth_header = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())?;
let token = auth_header.strip_prefix("Bearer ")?;
if let Some(entry) = inner.config.api_keys.get(token) {
return Some(entry.label.clone());
}
if let Some(ref validation) = inner.jwt_validation {
for (kid, key) in &inner.jwk_map {
let v = validation.clone();
if let Ok(data) = decode::<serde_json::Value>(token, key, &v) {
return data
.claims
.get("sub")
.and_then(|s| s.as_str())
.map(String::from);
}
let _ = kid;
}
}
None
}
pub fn is_bypass_path(&self, path: &str) -> bool {
self.inner
.config
.bypass_paths
.iter()
.any(|bp| path == bp.as_str())
}
pub fn failure_count(&self) -> u64 {
self.inner.failure_count.load(Ordering::Relaxed)
}
}
pub async fn auth_middleware(
State(auth): State<AuthState>,
req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
let path = req.uri().path().to_string();
if auth.is_bypass_path(&path) {
return Ok(next.run(req).await);
}
match auth.authenticate(&req) {
Some(_principal) => Ok(next.run(req).await),
None => {
auth.inner.failure_count.fetch_add(1, Ordering::Relaxed);
Ok((
StatusCode::UNAUTHORIZED,
[("www-authenticate", "Bearer")],
axum::Json(serde_json::json!({
"error": "unauthorized",
"message": "Valid API key or JWT required"
})),
)
.into_response())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_auth_always_passes() {
let config = AuthConfig {
enabled: false,
..Default::default()
};
let _auth = AuthState::new(config);
}
#[test]
fn bypass_paths_are_correct() {
let config = AuthConfig::default();
let auth = AuthState::new(config);
assert!(auth.is_bypass_path("/healthz"));
assert!(auth.is_bypass_path("/metrics"));
assert!(!auth.is_bypass_path("/v1/chat/completions"));
}
#[test]
fn static_api_key_authenticates() {
let config = AuthConfig {
enabled: true,
api_keys: {
let mut m = HashMap::new();
m.insert(
"sk-test-123".into(),
ApiKeyEntry {
label: "test-key".into(),
scopes: vec![],
},
);
m
},
..Default::default()
};
let auth = AuthState::new(config);
let req = Request::builder()
.uri("/v1/chat/completions")
.header("authorization", "Bearer sk-test-123")
.body(Body::empty())
.unwrap();
assert_eq!(auth.authenticate(&req), Some("test-key".into()));
}
}