Skip to main content

recursive/http/
auth.rs

1//! Authentication middleware and configuration for the HTTP server.
2//!
3//! Provides API-key and JWT bearer-token authentication. When no
4//! credentials are configured (the default), the middleware returns
5//! 503 Service Unavailable unless the explicit debug escape hatch
6//! `RECURSIVE_HTTP_AUTH_INSECURE_OK=1` is set. The escape hatch is
7//! **honoured only in debug builds** (`cargo run`, `cargo test`); a
8//! release build silently ignores it and returns 503 instead, so a
9//! Docker image or `--release` binary cannot be tricked into running
10//! unauthenticated by an operator "temporarily" setting the env var
11//! in production.
12
13use axum::http::StatusCode;
14use std::sync::Arc;
15
16/// API key authentication for the HTTP server.
17///
18/// Configured from `RECURSIVE_HTTP_AUTH_KEYS`, a comma-separated list of
19/// keys the server will accept in the `X-API-Key` request header. An empty
20/// key set (the default) disables auth entirely — every route is reachable
21/// without credentials. This preserves zero-config behavior and keeps the
22/// public default backward-compatible.
23///
24/// Distinct from `RECURSIVE_API_KEY` (singular): that variable holds the
25/// **outbound** credential the agent uses to talk to its LLM provider.
26/// `RECURSIVE_HTTP_AUTH_KEYS` (plural) holds the **inbound** credentials
27/// the HTTP server accepts from clients. The names are deliberately
28/// dissimilar to avoid confusion at the operator's shell.
29///
30/// `/health` and `/metrics` are always exempt (k8s liveness probes and
31/// Prometheus scrapers must work unauthenticated).
32#[derive(Clone, Default)]
33pub struct AuthConfig {
34    pub(super) keys: Arc<Vec<String>>,
35    pub(super) jwt: Option<JwtConfig>,
36}
37
38impl AuthConfig {
39    /// Build an `AuthConfig` from an explicit key list. Pass an empty
40    /// vec to disable API-key auth (a JWT verifier may still be
41    /// attached via [`AuthConfig::with_jwt`]).
42    pub fn new(keys: Vec<String>) -> Self {
43        Self {
44            keys: Arc::new(keys),
45            jwt: None,
46        }
47    }
48
49    /// Attach a JWT verifier. Call after [`AuthConfig::new`] to get
50    /// "X-API-Key OR Bearer JWT" semantics — either valid credential
51    /// type lets a request through. Without this call the behavior
52    /// is X-API-Key-only (the original g135 behavior).
53    pub fn with_jwt(mut self, jwt: JwtConfig) -> Self {
54        self.jwt = Some(jwt);
55        self
56    }
57
58    /// Constant-time check whether `presented` is in the configured
59    /// API-key set.
60    ///
61    /// Returns `false` when no API keys are configured — callers must
62    /// use [`AuthConfig::is_enabled`] first to detect the "auth
63    /// disabled" pass-through mode (handled by [`auth_middleware`]).
64    ///
65    /// The loop runs over **every** configured key regardless of an
66    /// early match, to keep the comparison constant-time and avoid
67    /// leaking key-set membership timing.
68    pub fn is_valid(&self, presented: &str) -> bool {
69        if self.keys.is_empty() {
70            return false;
71        }
72        let mut found = false;
73        let presented_bytes = presented.as_bytes();
74        for k in self.keys.iter() {
75            let k_bytes = k.as_bytes();
76            if k_bytes.len() != presented_bytes.len() {
77                continue;
78            }
79            let mut diff: u8 = 0;
80            for (a, b) in k_bytes.iter().zip(presented_bytes.iter()) {
81                diff |= a ^ b;
82            }
83            if diff == 0 {
84                found = true;
85            }
86        }
87        found
88    }
89
90    /// Whether ANY auth modality is enabled — non-empty API key set
91    /// OR a JWT verifier attached. When this returns `false`, the
92    /// middleware is a pass-through.
93    pub fn is_enabled(&self) -> bool {
94        !self.keys.is_empty() || self.jwt.is_some()
95    }
96}
97
98/// JWT bearer token verification config.
99///
100/// Verify-only: this server validates tokens minted elsewhere; it does
101/// not issue them. HS256 (HMAC-SHA256 with a shared secret) is the
102/// only supported algorithm in this revision — keeps secret management
103/// simple (one env var). RSA/ECDSA can be added later if a deployment
104/// needs JWKS-driven key rotation.
105///
106/// Configured from:
107/// - `RECURSIVE_HTTP_AUTH_JWT_SECRET` — HMAC secret bytes (UTF-8). Empty
108///   or unset disables JWT auth.
109/// - `RECURSIVE_HTTP_AUTH_JWT_AUDIENCE` — optional `aud` claim that
110///   tokens must contain. Unset = audience claim ignored (still valid
111///   JWT spec, just less strict).
112///
113/// `exp` claim is always required (RFC 7519 says optional; we make it
114/// mandatory to prevent unbounded-validity tokens).
115#[derive(Clone)]
116pub struct JwtConfig {
117    decoding_key: jsonwebtoken::DecodingKey,
118    validation: jsonwebtoken::Validation,
119}
120
121impl JwtConfig {
122    /// Build an HS256 verifier. Returns `None` if `secret` is empty
123    /// (parallels `AuthConfig`'s "empty = disabled" pattern).
124    ///
125    /// `audience` is optional: `Some("my-app")` requires tokens carry
126    /// `"aud": "my-app"`; `None` skips audience checking entirely.
127    pub fn hs256(secret: &str, audience: Option<String>) -> Option<Self> {
128        if secret.is_empty() {
129            return None;
130        }
131        let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
132        validation.set_required_spec_claims(&["exp"]);
133        if let Some(aud) = audience {
134            validation.set_audience(&[aud]);
135        } else {
136            validation.validate_aud = false;
137        }
138        Some(Self {
139            decoding_key: jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
140            validation,
141        })
142    }
143
144    /// Verify a token. Returns true iff signature, exp, and (when
145    /// configured) audience all check out.
146    pub fn is_valid(&self, token: &str) -> bool {
147        jsonwebtoken::decode::<serde_json::Value>(token, &self.decoding_key, &self.validation)
148            .is_ok()
149    }
150}
151
152/// Build `AuthConfig` from env vars:
153///
154/// - `RECURSIVE_HTTP_AUTH_KEYS` — comma-separated API keys (g135).
155/// - `RECURSIVE_HTTP_AUTH_JWT_SECRET` — HMAC secret for JWT (g136).
156/// - `RECURSIVE_HTTP_AUTH_JWT_AUDIENCE` — optional `aud` claim.
157///
158/// All unset = auth disabled (back-compat zero-config default).
159pub(super) fn auth_config_from_env() -> AuthConfig {
160    let raw = std::env::var("RECURSIVE_HTTP_AUTH_KEYS").unwrap_or_default();
161    let keys: Vec<String> = raw
162        .split(',')
163        .map(|s| s.trim().to_string())
164        .filter(|s| !s.is_empty())
165        .collect();
166    let mut config = AuthConfig::new(keys);
167    let jwt_secret = std::env::var("RECURSIVE_HTTP_AUTH_JWT_SECRET").unwrap_or_default();
168    let jwt_audience = std::env::var("RECURSIVE_HTTP_AUTH_JWT_AUDIENCE")
169        .ok()
170        .filter(|s| !s.is_empty());
171    if let Some(jwt) = JwtConfig::hs256(&jwt_secret, jwt_audience) {
172        config = config.with_jwt(jwt);
173    }
174    if !config.is_enabled() {
175        tracing::error!(
176            "HTTP auth is NOT configured. Set \
177             RECURSIVE_HTTP_AUTH_KEYS=... or RECURSIVE_HTTP_AUTH_JWT_SECRET=... \
178             to enable. For local dev only, set \
179             RECURSIVE_HTTP_AUTH_INSECURE_OK=1 to bypass (NEVER in production)."
180        );
181    }
182    config
183}
184
185/// Axum middleware: enforce auth on requests.
186///
187/// Tries `X-API-Key` first (cheap); falls back to
188/// `Authorization: Bearer <jwt>`. Either valid credential lets the
189/// request through.
190///
191/// Layered only over the protected sub-router — public routes
192/// (`/health`, `/metrics`, `/openapi.json`) are merged in at the
193/// top level without going through this middleware. See
194/// `build_router_with_auth_and_rate_limit` in `src/http/mod.rs`.
195///
196/// When auth is disabled (no API keys AND no JWT verifier
197/// configured) and `RECURSIVE_HTTP_AUTH_INSECURE_OK` is not set to
198/// `1` or `true`, the middleware returns 503 — default-deny for
199/// production safety (Goal 277 / SEC-003).
200pub(super) async fn auth_middleware(
201    axum::extract::State(auth): axum::extract::State<AuthConfig>,
202    req: axum::extract::Request,
203    next: axum::middleware::Next,
204) -> axum::response::Response {
205    if !auth.is_enabled() {
206        // SEC-007: `RECURSIVE_HTTP_AUTH_INSECURE_OK=1` is a development-only
207        // escape hatch. Release builds **ignore it** so a Docker image or
208        // built binary cannot be tricked into running unauthenticated by an
209        // operator "temporarily" setting the env var in production. Debug
210        // builds (the default `cargo run` / `cargo test` flow) still honour
211        // it so local dev stays frictionless.
212        let insecure_ok_set = std::env::var("RECURSIVE_HTTP_AUTH_INSECURE_OK")
213            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
214            .unwrap_or(false);
215        if insecure_ok_set && cfg!(debug_assertions) {
216            tracing::warn!(
217                "RECURSIVE_HTTP_AUTH_INSECURE_OK=1 set — bypassing auth. \
218                 Honoured because this is a debug build; release builds \
219                 ignore this switch and return 503 instead. Never use \
220                 this in production."
221            );
222            return next.run(req).await;
223        }
224        if insecure_ok_set && cfg!(not(debug_assertions)) {
225            tracing::error!(
226                "RECURSIVE_HTTP_AUTH_INSECURE_OK is set but ignored in \
227                 release builds. Configure RECURSIVE_HTTP_AUTH_KEYS or \
228                 RECURSIVE_HTTP_AUTH_JWT_SECRET, or unset the env var."
229            );
230        } else {
231            tracing::error!(
232                "HTTP server is running with NO auth configured. \
233                 Set RECURSIVE_HTTP_AUTH_KEYS=<comma-separated-keys> \
234                 or RECURSIVE_HTTP_AUTH_JWT_SECRET=<secret>. \
235                 (Debug builds also honour RECURSIVE_HTTP_AUTH_INSECURE_OK=1 \
236                 for local dev.)"
237            );
238        }
239        let mut resp = axum::response::Response::new(axum::body::Body::from(
240            "auth not configured; set RECURSIVE_HTTP_AUTH_KEYS or \
241             RECURSIVE_HTTP_AUTH_JWT_SECRET (release builds ignore \
242             RECURSIVE_HTTP_AUTH_INSECURE_OK)",
243        ));
244        *resp.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
245        return resp;
246    }
247    // Try X-API-Key first (cheaper than JWT verify).
248    if !auth.keys.is_empty() {
249        if let Some(presented) = req.headers().get("x-api-key").and_then(|v| v.to_str().ok()) {
250            if auth.is_valid(presented) {
251                return next.run(req).await;
252            }
253        }
254    }
255    // Then try Authorization: Bearer <jwt>.
256    if let Some(ref jwt) = auth.jwt {
257        if let Some(authz) = req
258            .headers()
259            .get("authorization")
260            .and_then(|v| v.to_str().ok())
261        {
262            if let Some(token) = authz.strip_prefix("Bearer ") {
263                if jwt.is_valid(token) {
264                    return next.run(req).await;
265                }
266            }
267        }
268    }
269    let mut resp = axum::response::Response::new(axum::body::Body::from("unauthorized"));
270    *resp.status_mut() = StatusCode::UNAUTHORIZED;
271    resp
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use axum::body::Body;
278    use axum::routing::get;
279    use tower::ServiceExt;
280
281    // ── AuthConfig::is_valid unit tests ──────────────────────────────────────
282
283    #[test]
284    fn auth_config_is_valid_accepts_correct_key() {
285        // kills `replace AuthConfig::is_valid -> bool with false` and `diff == 0` mutations
286        let cfg = AuthConfig::new(vec!["correct-key".to_string()]);
287        assert!(cfg.is_valid("correct-key"), "correct key must be accepted");
288    }
289
290    #[test]
291    fn auth_config_is_valid_rejects_wrong_key() {
292        // kills `replace AuthConfig::is_valid -> bool with true` mutation
293        let cfg = AuthConfig::new(vec!["correct-key".to_string()]);
294        assert!(!cfg.is_valid("wrong-key"), "wrong key must be rejected");
295    }
296
297    #[test]
298    fn auth_config_is_valid_rejects_prefix_of_correct_key() {
299        // kills length-check removal mutations (constant-time comparison)
300        let cfg = AuthConfig::new(vec!["long-key-value".to_string()]);
301        assert!(
302            !cfg.is_valid("long-key"),
303            "prefix of correct key must be rejected"
304        );
305    }
306
307    #[test]
308    fn auth_config_is_valid_returns_false_for_empty_key_set() {
309        // kills `if self.keys.is_empty() { return false; }` removal mutation
310        let cfg = AuthConfig::new(vec![]);
311        assert!(
312            !cfg.is_valid("anything"),
313            "empty key set must always reject"
314        );
315    }
316
317    #[test]
318    fn auth_config_is_valid_accepts_any_of_multiple_keys() {
319        // kills the `found = true` assignment being replaced with a return
320        let cfg = AuthConfig::new(vec!["key-a".to_string(), "key-b".to_string()]);
321        assert!(cfg.is_valid("key-a"), "first key must be accepted");
322        assert!(cfg.is_valid("key-b"), "second key must be accepted");
323    }
324
325    #[test]
326    fn auth_config_is_enabled_true_with_api_keys() {
327        // kills `replace AuthConfig::is_enabled -> bool with false`
328        let cfg = AuthConfig::new(vec!["k".to_string()]);
329        assert!(cfg.is_enabled());
330    }
331
332    #[test]
333    fn auth_config_is_enabled_false_without_keys_or_jwt() {
334        // kills `replace !self.keys.is_empty() with false` or `|| self.jwt.is_some()` mutations
335        let cfg = AuthConfig::new(vec![]);
336        assert!(!cfg.is_enabled(), "no keys and no JWT must be disabled");
337    }
338
339    fn router_with_auth(auth: AuthConfig) -> axum::Router {
340        axum::Router::new()
341            .route("/", get(|| async { "ok" }))
342            .layer(axum::middleware::from_fn_with_state(auth, auth_middleware))
343    }
344
345    /// Goal 277: When auth is not configured and INSECURE_OK is unset,
346    /// the middleware returns 503. With INSECURE_OK=1, it passes through.
347    /// Single combined test to avoid env-var races.
348    #[tokio::test]
349    async fn auth_inscure_ok_toggles() {
350        // Ensure we start from a clean env for this test binary.
351        unsafe {
352            std::env::remove_var("RECURSIVE_HTTP_AUTH_INSECURE_OK");
353        }
354
355        let auth = AuthConfig::default(); // is_enabled() == false
356
357        // --- Without INSECURE_OK: expect 503 ---
358        {
359            let app = router_with_auth(auth.clone());
360            let resp = app
361                .oneshot(
362                    axum::extract::Request::builder()
363                        .uri("/")
364                        .body(Body::empty())
365                        .unwrap(),
366                )
367                .await
368                .unwrap();
369            assert_eq!(
370                resp.status(),
371                StatusCode::SERVICE_UNAVAILABLE,
372                "expected 503 without INSECURE_OK"
373            );
374        }
375
376        // --- INSECURE_OK=1: passes through (200) ---
377        unsafe {
378            std::env::set_var("RECURSIVE_HTTP_AUTH_INSECURE_OK", "1");
379        }
380        {
381            let app = router_with_auth(auth.clone());
382            let resp = app
383                .oneshot(
384                    axum::extract::Request::builder()
385                        .uri("/")
386                        .body(Body::empty())
387                        .unwrap(),
388                )
389                .await
390                .unwrap();
391            assert_eq!(
392                resp.status(),
393                StatusCode::OK,
394                "expected 200 with INSECURE_OK=1"
395            );
396        }
397
398        // --- INSECURE_OK=0 (falsy): expect 503 ---
399        unsafe {
400            std::env::set_var("RECURSIVE_HTTP_AUTH_INSECURE_OK", "0");
401        }
402        {
403            let app = router_with_auth(auth.clone());
404            let resp = app
405                .oneshot(
406                    axum::extract::Request::builder()
407                        .uri("/")
408                        .body(Body::empty())
409                        .unwrap(),
410                )
411                .await
412                .unwrap();
413            assert_eq!(
414                resp.status(),
415                StatusCode::SERVICE_UNAVAILABLE,
416                "expected 503 with INSECURE_OK=0"
417            );
418        }
419
420        // --- INSECURE_OK=true (case-insensitive): passes through (200) ---
421        unsafe {
422            std::env::set_var("RECURSIVE_HTTP_AUTH_INSECURE_OK", "true");
423        }
424        {
425            let app = router_with_auth(auth.clone());
426            let resp = app
427                .oneshot(
428                    axum::extract::Request::builder()
429                        .uri("/")
430                        .body(Body::empty())
431                        .unwrap(),
432                )
433                .await
434                .unwrap();
435            assert_eq!(
436                resp.status(),
437                StatusCode::OK,
438                "expected 200 with INSECURE_OK=true"
439            );
440        }
441
442        // Clean up.
443        unsafe {
444            std::env::remove_var("RECURSIVE_HTTP_AUTH_INSECURE_OK");
445        }
446    }
447
448    // SEC-007 regression test: the bypass must be gated on `cfg!(debug_assertions)`,
449    // and the release branch must explicitly reject it. We use source-grep
450    // because a runtime test cannot observe `cfg!` from inside the test
451    // binary (which is always compiled with debug_assertions). This mirrors
452    // the pattern used in `src/http/mod.rs::goal_272_route_level_auth_bypass`
453    // for pinning the structural auth invariant.
454    #[test]
455    fn insecure_ok_bypass_is_gated_on_debug_assertions() {
456        let src = include_str!("auth.rs");
457        let middleware_body = src
458            .split("pub(super) async fn auth_middleware")
459            .nth(1)
460            .expect("auth_middleware must exist");
461        // The bypass branch must be conditional on cfg!(debug_assertions).
462        // Without this gate the bypass would also fire in release builds,
463        // defeating the whole point of SEC-007.
464        assert!(
465            middleware_body.contains("cfg!(debug_assertions)"),
466            "auth_middleware must gate INSECURE_OK bypass on cfg!(debug_assertions)"
467        );
468        // And the release branch must explicitly check + log when the env
469        // var was set but ignored, so an operator who sets it in prod gets
470        // a loud error rather than a silent 503 with no clue why.
471        assert!(
472            middleware_body.contains("cfg!(not(debug_assertions))"),
473            "auth_middleware must have an explicit release-build branch that \
474             surfaces a misuse warning when INSECURE_OK is set"
475        );
476    }
477}