use std::env;
use std::error::Error;
use std::fmt;
use std::net::IpAddr;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use subtle::ConstantTimeEq;
use crate::openai_types::ErrorResponse;
const API_KEY_ENV: &str = "EMBACLE_API_KEY";
pub async fn require_auth(request: Request, next: Next) -> Response {
let expected_key = match env::var(API_KEY_ENV) {
Ok(key) if !key.is_empty() => key,
_ => return next.run(request).await,
};
let auth_header = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok());
match auth_header {
Some(header) if header.starts_with("Bearer ") => {
let token = &header.as_bytes()["Bearer ".len()..];
let expected = expected_key.as_bytes();
if token.ct_eq(expected).into() {
next.run(request).await
} else {
auth_error("Invalid API key")
}
}
Some(_) => auth_error("Authorization header must use Bearer scheme"),
None => auth_error("Missing Authorization header"),
}
}
fn auth_error(message: &str) -> Response {
let body = ErrorResponse::new("authentication_error", message);
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
}
pub fn api_key_configured() -> bool {
matches!(env::var(API_KEY_ENV), Ok(key) if !key.is_empty())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMode {
Enforced,
LoopbackDev,
}
#[derive(Debug, Clone)]
pub struct InsecureBindError {
pub host: String,
}
impl fmt::Display for InsecureBindError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"refusing to start: no {API_KEY_ENV} set while binding non-loopback host '{}'. \
Set {API_KEY_ENV} to require authentication, or bind 127.0.0.1 for local development.",
self.host
)
}
}
impl Error for InsecureBindError {}
fn is_loopback_host(host: &str) -> bool {
let trimmed = host.trim();
if trimmed.eq_ignore_ascii_case("localhost") {
return true;
}
let stripped = trimmed
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(trimmed);
stripped.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
pub fn resolve_startup_auth(host: &str, has_api_key: bool) -> Result<AuthMode, InsecureBindError> {
if has_api_key {
Ok(AuthMode::Enforced)
} else if is_loopback_host(host) {
Ok(AuthMode::LoopbackDev)
} else {
Err(InsecureBindError {
host: host.to_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_key_env_is_correct() {
assert_eq!(API_KEY_ENV, "EMBACLE_API_KEY");
}
#[test]
fn loopback_hosts_are_recognized() {
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.1.2.3"));
assert!(is_loopback_host("::1"));
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("localhost"));
assert!(is_loopback_host("LocalHost"));
}
#[test]
fn non_loopback_hosts_are_rejected() {
assert!(!is_loopback_host("0.0.0.0"));
assert!(!is_loopback_host("::"));
assert!(!is_loopback_host("192.168.1.10"));
assert!(!is_loopback_host("example.com"));
assert!(!is_loopback_host(""));
}
#[test]
fn refuses_to_start_non_loopback_without_key() {
let err = resolve_startup_auth("0.0.0.0", false)
.expect_err("non-loopback bind with no key must be refused");
assert_eq!(err.host, "0.0.0.0");
assert!(err.to_string().contains("refusing to start"));
assert!(err.to_string().contains("EMBACLE_API_KEY"));
}
#[test]
fn allows_loopback_dev_without_key() {
assert!(matches!(
resolve_startup_auth("127.0.0.1", false),
Ok(AuthMode::LoopbackDev)
));
}
#[test]
fn enforces_when_key_present_on_any_host() {
assert!(matches!(
resolve_startup_auth("0.0.0.0", true),
Ok(AuthMode::Enforced)
));
assert!(matches!(
resolve_startup_auth("127.0.0.1", true),
Ok(AuthMode::Enforced)
));
}
}