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}