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 credentials
4//! are configured (the default), all routes are reachable without auth —
5//! preserving the zero-config backward-compatible behavior.
6
7use axum::http::StatusCode;
8use std::sync::Arc;
9
10/// API key authentication for the HTTP server.
11///
12/// Configured from `RECURSIVE_HTTP_AUTH_KEYS`, a comma-separated list of
13/// keys the server will accept in the `X-API-Key` request header. An empty
14/// key set (the default) disables auth entirely — every route is reachable
15/// without credentials. This preserves zero-config behavior and keeps the
16/// public default backward-compatible.
17///
18/// Distinct from `RECURSIVE_API_KEY` (singular): that variable holds the
19/// **outbound** credential the agent uses to talk to its LLM provider.
20/// `RECURSIVE_HTTP_AUTH_KEYS` (plural) holds the **inbound** credentials
21/// the HTTP server accepts from clients. The names are deliberately
22/// dissimilar to avoid confusion at the operator's shell.
23///
24/// `/health` and `/metrics` are always exempt (k8s liveness probes and
25/// Prometheus scrapers must work unauthenticated).
26#[derive(Clone, Default)]
27pub struct AuthConfig {
28    pub(super) keys: Arc<Vec<String>>,
29    pub(super) jwt: Option<JwtConfig>,
30}
31
32impl AuthConfig {
33    /// Build an `AuthConfig` from an explicit key list. Pass an empty
34    /// vec to disable API-key auth (a JWT verifier may still be
35    /// attached via [`AuthConfig::with_jwt`]).
36    pub fn new(keys: Vec<String>) -> Self {
37        Self {
38            keys: Arc::new(keys),
39            jwt: None,
40        }
41    }
42
43    /// Attach a JWT verifier. Call after [`AuthConfig::new`] to get
44    /// "X-API-Key OR Bearer JWT" semantics — either valid credential
45    /// type lets a request through. Without this call the behavior
46    /// is X-API-Key-only (the original g135 behavior).
47    pub fn with_jwt(mut self, jwt: JwtConfig) -> Self {
48        self.jwt = Some(jwt);
49        self
50    }
51
52    /// Constant-time check whether `presented` is in the configured
53    /// API-key set.
54    ///
55    /// Returns `true` if no API keys are configured. Endpoints must
56    /// rely on the middleware layering, not this method, for the
57    /// "auth disabled" semantics — see [`auth_middleware`].
58    ///
59    /// The loop runs over **every** configured key regardless of an
60    /// early match, to keep the comparison constant-time and avoid
61    /// leaking key-set membership timing.
62    pub fn is_valid(&self, presented: &str) -> bool {
63        if self.keys.is_empty() {
64            return true;
65        }
66        let mut found = false;
67        let presented_bytes = presented.as_bytes();
68        for k in self.keys.iter() {
69            let k_bytes = k.as_bytes();
70            if k_bytes.len() != presented_bytes.len() {
71                continue;
72            }
73            let mut diff: u8 = 0;
74            for (a, b) in k_bytes.iter().zip(presented_bytes.iter()) {
75                diff |= a ^ b;
76            }
77            if diff == 0 {
78                found = true;
79            }
80        }
81        found
82    }
83
84    /// Whether ANY auth modality is enabled — non-empty API key set
85    /// OR a JWT verifier attached. When this returns `false`, the
86    /// middleware is a pass-through.
87    pub fn is_enabled(&self) -> bool {
88        !self.keys.is_empty() || self.jwt.is_some()
89    }
90}
91
92/// JWT bearer token verification config.
93///
94/// Verify-only: this server validates tokens minted elsewhere; it does
95/// not issue them. HS256 (HMAC-SHA256 with a shared secret) is the
96/// only supported algorithm in this revision — keeps secret management
97/// simple (one env var). RSA/ECDSA can be added later if a deployment
98/// needs JWKS-driven key rotation.
99///
100/// Configured from:
101/// - `RECURSIVE_HTTP_AUTH_JWT_SECRET` — HMAC secret bytes (UTF-8). Empty
102///   or unset disables JWT auth.
103/// - `RECURSIVE_HTTP_AUTH_JWT_AUDIENCE` — optional `aud` claim that
104///   tokens must contain. Unset = audience claim ignored (still valid
105///   JWT spec, just less strict).
106///
107/// `exp` claim is always required (RFC 7519 says optional; we make it
108/// mandatory to prevent unbounded-validity tokens).
109#[derive(Clone)]
110pub struct JwtConfig {
111    decoding_key: jsonwebtoken::DecodingKey,
112    validation: jsonwebtoken::Validation,
113}
114
115impl JwtConfig {
116    /// Build an HS256 verifier. Returns `None` if `secret` is empty
117    /// (parallels `AuthConfig`'s "empty = disabled" pattern).
118    ///
119    /// `audience` is optional: `Some("my-app")` requires tokens carry
120    /// `"aud": "my-app"`; `None` skips audience checking entirely.
121    pub fn hs256(secret: &str, audience: Option<String>) -> Option<Self> {
122        if secret.is_empty() {
123            return None;
124        }
125        let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
126        validation.set_required_spec_claims(&["exp"]);
127        if let Some(aud) = audience {
128            validation.set_audience(&[aud]);
129        } else {
130            validation.validate_aud = false;
131        }
132        Some(Self {
133            decoding_key: jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()),
134            validation,
135        })
136    }
137
138    /// Verify a token. Returns true iff signature, exp, and (when
139    /// configured) audience all check out.
140    pub fn is_valid(&self, token: &str) -> bool {
141        jsonwebtoken::decode::<serde_json::Value>(token, &self.decoding_key, &self.validation)
142            .is_ok()
143    }
144}
145
146/// Build `AuthConfig` from env vars:
147///
148/// - `RECURSIVE_HTTP_AUTH_KEYS` — comma-separated API keys (g135).
149/// - `RECURSIVE_HTTP_AUTH_JWT_SECRET` — HMAC secret for JWT (g136).
150/// - `RECURSIVE_HTTP_AUTH_JWT_AUDIENCE` — optional `aud` claim.
151///
152/// All unset = auth disabled (back-compat zero-config default).
153pub(super) fn auth_config_from_env() -> AuthConfig {
154    let raw = std::env::var("RECURSIVE_HTTP_AUTH_KEYS").unwrap_or_default();
155    let keys: Vec<String> = raw
156        .split(',')
157        .map(|s| s.trim().to_string())
158        .filter(|s| !s.is_empty())
159        .collect();
160    let mut config = AuthConfig::new(keys);
161    let jwt_secret = std::env::var("RECURSIVE_HTTP_AUTH_JWT_SECRET").unwrap_or_default();
162    let jwt_audience = std::env::var("RECURSIVE_HTTP_AUTH_JWT_AUDIENCE")
163        .ok()
164        .filter(|s| !s.is_empty());
165    if let Some(jwt) = JwtConfig::hs256(&jwt_secret, jwt_audience) {
166        config = config.with_jwt(jwt);
167    }
168    config
169}
170
171/// Axum middleware: enforce auth on requests.
172///
173/// Tries `X-API-Key` first (cheap); falls back to
174/// `Authorization: Bearer <jwt>`. Either valid credential lets the
175/// request through.
176///
177/// Layered after the router so that all routes pass through it, but
178/// `/health` and `/metrics` are explicitly exempted to keep liveness
179/// probes and metrics scraping reachable without credentials.
180///
181/// When auth is disabled (no API keys AND no JWT verifier
182/// configured), the middleware is a no-op pass-through — preserving
183/// back-compat zero-config behavior.
184pub(super) async fn auth_middleware(
185    axum::extract::State(auth): axum::extract::State<AuthConfig>,
186    req: axum::extract::Request,
187    next: axum::middleware::Next,
188) -> axum::response::Response {
189    if !auth.is_enabled() {
190        return next.run(req).await;
191    }
192    let path = req.uri().path();
193    if path == "/health" || path == "/metrics" {
194        return next.run(req).await;
195    }
196    // Try X-API-Key first (cheaper than JWT verify).
197    if !auth.keys.is_empty() {
198        if let Some(presented) = req.headers().get("x-api-key").and_then(|v| v.to_str().ok()) {
199            if auth.is_valid(presented) {
200                return next.run(req).await;
201            }
202        }
203    }
204    // Then try Authorization: Bearer <jwt>.
205    if let Some(ref jwt) = auth.jwt {
206        if let Some(authz) = req
207            .headers()
208            .get("authorization")
209            .and_then(|v| v.to_str().ok())
210        {
211            if let Some(token) = authz.strip_prefix("Bearer ") {
212                if jwt.is_valid(token) {
213                    return next.run(req).await;
214                }
215            }
216        }
217    }
218    let mut resp = axum::response::Response::new(axum::body::Body::from("unauthorized"));
219    *resp.status_mut() = StatusCode::UNAUTHORIZED;
220    resp
221}