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}