1use std::env;
8use std::error::Error;
9use std::fmt;
10use std::net::IpAddr;
11
12use axum::extract::Request;
13use axum::http::StatusCode;
14use axum::middleware::Next;
15use axum::response::{IntoResponse, Response};
16use axum::Json;
17use subtle::ConstantTimeEq;
18
19use crate::openai_types::ErrorResponse;
20
21const API_KEY_ENV: &str = "EMBACLE_API_KEY";
23
24pub async fn require_auth(request: Request, next: Next) -> Response {
31 let expected_key = match env::var(API_KEY_ENV) {
32 Ok(key) if !key.is_empty() => key,
33 _ => return next.run(request).await,
34 };
35
36 let auth_header = request
37 .headers()
38 .get("authorization")
39 .and_then(|v| v.to_str().ok());
40
41 match auth_header {
42 Some(header) if header.starts_with("Bearer ") => {
43 let token = &header.as_bytes()["Bearer ".len()..];
44 let expected = expected_key.as_bytes();
45 if token.ct_eq(expected).into() {
46 next.run(request).await
47 } else {
48 auth_error("Invalid API key")
49 }
50 }
51 Some(_) => auth_error("Authorization header must use Bearer scheme"),
52 None => auth_error("Missing Authorization header"),
53 }
54}
55
56fn auth_error(message: &str) -> Response {
58 let body = ErrorResponse::new("authentication_error", message);
59 (StatusCode::UNAUTHORIZED, Json(body)).into_response()
60}
61
62pub fn api_key_configured() -> bool {
64 matches!(env::var(API_KEY_ENV), Ok(key) if !key.is_empty())
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum AuthMode {
70 Enforced,
72 LoopbackDev,
74}
75
76#[derive(Debug, Clone)]
81pub struct InsecureBindError {
82 pub host: String,
84}
85
86impl fmt::Display for InsecureBindError {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 write!(
89 f,
90 "refusing to start: no {API_KEY_ENV} set while binding non-loopback host '{}'. \
91 Set {API_KEY_ENV} to require authentication, or bind 127.0.0.1 for local development.",
92 self.host
93 )
94 }
95}
96
97impl Error for InsecureBindError {}
98
99fn is_loopback_host(host: &str) -> bool {
105 let trimmed = host.trim();
106 if trimmed.eq_ignore_ascii_case("localhost") {
107 return true;
108 }
109 let stripped = trimmed
110 .strip_prefix('[')
111 .and_then(|s| s.strip_suffix(']'))
112 .unwrap_or(trimmed);
113 stripped.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
114}
115
116pub fn resolve_startup_auth(host: &str, has_api_key: bool) -> Result<AuthMode, InsecureBindError> {
122 if has_api_key {
123 Ok(AuthMode::Enforced)
124 } else if is_loopback_host(host) {
125 Ok(AuthMode::LoopbackDev)
126 } else {
127 Err(InsecureBindError {
128 host: host.to_owned(),
129 })
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn api_key_env_is_correct() {
139 assert_eq!(API_KEY_ENV, "EMBACLE_API_KEY");
140 }
141
142 #[test]
143 fn loopback_hosts_are_recognized() {
144 assert!(is_loopback_host("127.0.0.1"));
145 assert!(is_loopback_host("127.1.2.3"));
146 assert!(is_loopback_host("::1"));
147 assert!(is_loopback_host("[::1]"));
148 assert!(is_loopback_host("localhost"));
149 assert!(is_loopback_host("LocalHost"));
150 }
151
152 #[test]
153 fn non_loopback_hosts_are_rejected() {
154 assert!(!is_loopback_host("0.0.0.0"));
155 assert!(!is_loopback_host("::"));
156 assert!(!is_loopback_host("192.168.1.10"));
157 assert!(!is_loopback_host("example.com"));
158 assert!(!is_loopback_host(""));
159 }
160
161 #[test]
162 fn refuses_to_start_non_loopback_without_key() {
163 let err = resolve_startup_auth("0.0.0.0", false)
166 .expect_err("non-loopback bind with no key must be refused");
167 assert_eq!(err.host, "0.0.0.0");
168 assert!(err.to_string().contains("refusing to start"));
169 assert!(err.to_string().contains("EMBACLE_API_KEY"));
170 }
171
172 #[test]
173 fn allows_loopback_dev_without_key() {
174 assert!(matches!(
175 resolve_startup_auth("127.0.0.1", false),
176 Ok(AuthMode::LoopbackDev)
177 ));
178 }
179
180 #[test]
181 fn enforces_when_key_present_on_any_host() {
182 assert!(matches!(
183 resolve_startup_auth("0.0.0.0", true),
184 Ok(AuthMode::Enforced)
185 ));
186 assert!(matches!(
187 resolve_startup_auth("127.0.0.1", true),
188 Ok(AuthMode::Enforced)
189 ));
190 }
191}