use axum::http::StatusCode;
use std::sync::Arc;
#[derive(Clone, Default)]
pub struct AuthConfig {
pub(super) keys: Arc<Vec<String>>,
pub(super) jwt: Option<JwtConfig>,
}
impl AuthConfig {
pub fn new(keys: Vec<String>) -> Self {
Self {
keys: Arc::new(keys),
jwt: None,
}
}
pub fn with_jwt(mut self, jwt: JwtConfig) -> Self {
self.jwt = Some(jwt);
self
}
pub fn is_valid(&self, presented: &str) -> bool {
if self.keys.is_empty() {
return false;
}
let mut found = false;
let presented_bytes = presented.as_bytes();
for k in self.keys.iter() {
let k_bytes = k.as_bytes();
if k_bytes.len() != presented_bytes.len() {
continue;
}
let mut diff: u8 = 0;
for (a, b) in k_bytes.iter().zip(presented_bytes.iter()) {
diff |= a ^ b;
}
if diff == 0 {
found = true;
}
}
found
}
pub fn is_enabled(&self) -> bool {
!self.keys.is_empty() || self.jwt.is_some()
}
}
#[derive(Clone)]
pub struct JwtConfig {
decoding_key: jsonwebtoken::DecodingKey,
validation: jsonwebtoken::Validation,
}
impl JwtConfig {
pub fn hs256(secret: &str, audience: Option<String>) -> Option<Self> {
if secret.is_empty() {
return None;
}
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
validation.set_required_spec_claims(&["exp"]);
if let Some(aud) = audience {
validation.set_audience(&[aud]);
} else {
validation.validate_aud = false;
}
Some(Self {
decoding_key: jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
validation,
})
}
pub fn is_valid(&self, token: &str) -> bool {
jsonwebtoken::decode::<serde_json::Value>(token, &self.decoding_key, &self.validation)
.is_ok()
}
}
pub(super) fn auth_config_from_env() -> AuthConfig {
let raw = std::env::var("RECURSIVE_HTTP_AUTH_KEYS").unwrap_or_default();
let keys: Vec<String> = raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let mut config = AuthConfig::new(keys);
let jwt_secret = std::env::var("RECURSIVE_HTTP_AUTH_JWT_SECRET").unwrap_or_default();
let jwt_audience = std::env::var("RECURSIVE_HTTP_AUTH_JWT_AUDIENCE")
.ok()
.filter(|s| !s.is_empty());
if let Some(jwt) = JwtConfig::hs256(&jwt_secret, jwt_audience) {
config = config.with_jwt(jwt);
}
if !config.is_enabled() {
tracing::error!(
"HTTP auth is NOT configured. Set \
RECURSIVE_HTTP_AUTH_KEYS=... or RECURSIVE_HTTP_AUTH_JWT_SECRET=... \
to enable. For local dev only, set \
RECURSIVE_HTTP_AUTH_INSECURE_OK=1 to bypass (NEVER in production)."
);
}
config
}
pub(super) async fn auth_middleware(
axum::extract::State(auth): axum::extract::State<AuthConfig>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
if !auth.is_enabled() {
let insecure_ok_set = std::env::var("RECURSIVE_HTTP_AUTH_INSECURE_OK")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if insecure_ok_set && cfg!(debug_assertions) {
tracing::warn!(
"RECURSIVE_HTTP_AUTH_INSECURE_OK=1 set — bypassing auth. \
Honoured because this is a debug build; release builds \
ignore this switch and return 503 instead. Never use \
this in production."
);
return next.run(req).await;
}
if insecure_ok_set && cfg!(not(debug_assertions)) {
tracing::error!(
"RECURSIVE_HTTP_AUTH_INSECURE_OK is set but ignored in \
release builds. Configure RECURSIVE_HTTP_AUTH_KEYS or \
RECURSIVE_HTTP_AUTH_JWT_SECRET, or unset the env var."
);
} else {
tracing::error!(
"HTTP server is running with NO auth configured. \
Set RECURSIVE_HTTP_AUTH_KEYS=<comma-separated-keys> \
or RECURSIVE_HTTP_AUTH_JWT_SECRET=<secret>. \
(Debug builds also honour RECURSIVE_HTTP_AUTH_INSECURE_OK=1 \
for local dev.)"
);
}
let mut resp = axum::response::Response::new(axum::body::Body::from(
"auth not configured; set RECURSIVE_HTTP_AUTH_KEYS or \
RECURSIVE_HTTP_AUTH_JWT_SECRET (release builds ignore \
RECURSIVE_HTTP_AUTH_INSECURE_OK)",
));
*resp.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
return resp;
}
if !auth.keys.is_empty() {
if let Some(presented) = req.headers().get("x-api-key").and_then(|v| v.to_str().ok()) {
if auth.is_valid(presented) {
return next.run(req).await;
}
}
}
if let Some(ref jwt) = auth.jwt {
if let Some(authz) = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
{
if let Some(token) = authz.strip_prefix("Bearer ") {
if jwt.is_valid(token) {
return next.run(req).await;
}
}
}
}
let mut resp = axum::response::Response::new(axum::body::Body::from("unauthorized"));
*resp.status_mut() = StatusCode::UNAUTHORIZED;
resp
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::routing::get;
use tower::ServiceExt;
#[test]
fn auth_config_is_valid_accepts_correct_key() {
let cfg = AuthConfig::new(vec!["correct-key".to_string()]);
assert!(cfg.is_valid("correct-key"), "correct key must be accepted");
}
#[test]
fn auth_config_is_valid_rejects_wrong_key() {
let cfg = AuthConfig::new(vec!["correct-key".to_string()]);
assert!(!cfg.is_valid("wrong-key"), "wrong key must be rejected");
}
#[test]
fn auth_config_is_valid_rejects_prefix_of_correct_key() {
let cfg = AuthConfig::new(vec!["long-key-value".to_string()]);
assert!(
!cfg.is_valid("long-key"),
"prefix of correct key must be rejected"
);
}
#[test]
fn auth_config_is_valid_returns_false_for_empty_key_set() {
let cfg = AuthConfig::new(vec![]);
assert!(
!cfg.is_valid("anything"),
"empty key set must always reject"
);
}
#[test]
fn auth_config_is_valid_accepts_any_of_multiple_keys() {
let cfg = AuthConfig::new(vec!["key-a".to_string(), "key-b".to_string()]);
assert!(cfg.is_valid("key-a"), "first key must be accepted");
assert!(cfg.is_valid("key-b"), "second key must be accepted");
}
#[test]
fn auth_config_is_enabled_true_with_api_keys() {
let cfg = AuthConfig::new(vec!["k".to_string()]);
assert!(cfg.is_enabled());
}
#[test]
fn auth_config_is_enabled_false_without_keys_or_jwt() {
let cfg = AuthConfig::new(vec![]);
assert!(!cfg.is_enabled(), "no keys and no JWT must be disabled");
}
fn router_with_auth(auth: AuthConfig) -> axum::Router {
axum::Router::new()
.route("/", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(auth, auth_middleware))
}
#[tokio::test]
async fn auth_inscure_ok_toggles() {
unsafe {
std::env::remove_var("RECURSIVE_HTTP_AUTH_INSECURE_OK");
}
let auth = AuthConfig::default();
{
let app = router_with_auth(auth.clone());
let resp = app
.oneshot(
axum::extract::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"expected 503 without INSECURE_OK"
);
}
unsafe {
std::env::set_var("RECURSIVE_HTTP_AUTH_INSECURE_OK", "1");
}
{
let app = router_with_auth(auth.clone());
let resp = app
.oneshot(
axum::extract::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"expected 200 with INSECURE_OK=1"
);
}
unsafe {
std::env::set_var("RECURSIVE_HTTP_AUTH_INSECURE_OK", "0");
}
{
let app = router_with_auth(auth.clone());
let resp = app
.oneshot(
axum::extract::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"expected 503 with INSECURE_OK=0"
);
}
unsafe {
std::env::set_var("RECURSIVE_HTTP_AUTH_INSECURE_OK", "true");
}
{
let app = router_with_auth(auth.clone());
let resp = app
.oneshot(
axum::extract::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"expected 200 with INSECURE_OK=true"
);
}
unsafe {
std::env::remove_var("RECURSIVE_HTTP_AUTH_INSECURE_OK");
}
}
#[test]
fn insecure_ok_bypass_is_gated_on_debug_assertions() {
let src = include_str!("auth.rs");
let middleware_body = src
.split("pub(super) async fn auth_middleware")
.nth(1)
.expect("auth_middleware must exist");
assert!(
middleware_body.contains("cfg!(debug_assertions)"),
"auth_middleware must gate INSECURE_OK bypass on cfg!(debug_assertions)"
);
assert!(
middleware_body.contains("cfg!(not(debug_assertions))"),
"auth_middleware must have an explicit release-build branch that \
surfaces a misuse warning when INSECURE_OK is set"
);
}
}