Skip to main content

embacle_server/
auth.rs

1// ABOUTME: Optional bearer token authentication middleware for the REST API
2// ABOUTME: Enforces EMBACLE_API_KEY when set; unauthenticated access is allowed only on a loopback bind
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use 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
21/// Environment variable name for the API key
22const API_KEY_ENV: &str = "EMBACLE_API_KEY";
23
24/// Middleware that validates the bearer token against `EMBACLE_API_KEY`
25///
26/// The env var is read on every request to allow runtime key rotation
27/// without restarting the server. If the variable is not set, all requests
28/// are allowed through (localhost development mode). If set, requests must
29/// include a matching `Authorization: Bearer <key>` header.
30pub 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
56/// Build a 401 error response
57fn auth_error(message: &str) -> Response {
58    let body = ErrorResponse::new("authentication_error", message);
59    (StatusCode::UNAUTHORIZED, Json(body)).into_response()
60}
61
62/// Whether `EMBACLE_API_KEY` is set to a non-empty value
63pub fn api_key_configured() -> bool {
64    matches!(env::var(API_KEY_ENV), Ok(key) if !key.is_empty())
65}
66
67/// Startup authentication posture resolved from the bind host and key presence
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum AuthMode {
70    /// `EMBACLE_API_KEY` is set — every request is authenticated
71    Enforced,
72    /// No key set, but the bind is loopback-only — unauthenticated dev access
73    LoopbackDev,
74}
75
76/// Refusal to start unauthenticated on a non-loopback bind
77///
78/// embacle is the guardian gate, so it fails closed: exposing tool execution
79/// on a reachable interface without an API key is never permitted.
80#[derive(Debug, Clone)]
81pub struct InsecureBindError {
82    /// The non-loopback host the server was asked to bind
83    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
99/// Whether `host` resolves to a loopback interface (`127.0.0.0/8`, `::1`, localhost)
100///
101/// Anything that does not parse as a loopback address — including `0.0.0.0`,
102/// `::`, and unknown names — is treated as non-loopback so the guardian fails
103/// closed rather than open.
104fn 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
116/// Resolve the startup authentication posture for an HTTP bind.
117///
118/// - Key set → [`AuthMode::Enforced`] regardless of host.
119/// - No key + loopback host → [`AuthMode::LoopbackDev`] (caller should warn).
120/// - No key + non-loopback host → [`InsecureBindError`]; the server must not start.
121pub 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        // The core regression: guardian must fail closed, not open, when
164        // exposed on a reachable interface with no API key configured.
165        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}