dravr_tronc/server/auth.rs
1// ABOUTME: Configurable bearer token authentication middleware for Axum REST APIs
2// ABOUTME: Reads API key from a caller-specified env var, allows unauthenticated when unset
3//
4// SPDX-License-Identifier: MIT OR Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Shared-bearer-key middleware for Axum, and the startup posture check.
8//!
9//! [`require_auth`] **fails open**: with its environment variable unset every
10//! request passes through. That is deliberate and pinned by tests — stdio and
11//! local runs depend on it — and it is why [`resolve_startup_auth`] exists, to
12//! refuse a reachable bind that nothing gates.
13//!
14//! **Which mechanism?** This crate ships five, and the choice is made by what
15//! the caller can present, per route — not by which one has no feature flag.
16//! The table is in the crate README under "Choosing an auth mechanism"; read it
17//! before reaching for a shared bearer key, which is the wrong answer whenever
18//! the caller is another Google workload, a browser session, or a webhook.
19
20use std::env;
21use std::error::Error;
22use std::fmt;
23use std::net::IpAddr;
24
25use axum::extract::Request;
26use axum::http::StatusCode;
27use axum::middleware::Next;
28use axum::response::{IntoResponse, Response};
29use axum::Json;
30use subtle::ConstantTimeEq;
31
32use crate::error::ErrorResponse;
33
34/// Create an Axum middleware function that validates bearer tokens
35///
36/// Reads the API key from the given environment variable on every request
37/// to allow runtime key rotation without restarting. If the variable is not
38/// set or empty, all requests pass through (development mode).
39///
40/// # Usage
41///
42/// ```rust,ignore
43/// use axum::middleware;
44/// use dravr_tronc::server::auth::require_auth;
45///
46/// let app = Router::new()
47/// .route("/api/endpoint", get(handler))
48/// .layer(middleware::from_fn(|req, next| {
49/// require_auth("MY_API_KEY_ENV", req, next)
50/// }));
51/// ```
52pub async fn require_auth(env_var: &str, request: Request, next: Next) -> Response {
53 let expected_key = match env::var(env_var) {
54 Ok(key) if !key.is_empty() => key,
55 _ => return next.run(request).await,
56 };
57
58 let auth_header = request
59 .headers()
60 .get("authorization")
61 .and_then(|v| v.to_str().ok());
62
63 match auth_header {
64 Some(header) if header.starts_with("Bearer ") => {
65 let token = &header.as_bytes()["Bearer ".len()..];
66 let expected = expected_key.as_bytes();
67 if token.ct_eq(expected).into() {
68 next.run(request).await
69 } else {
70 auth_error("Invalid API key")
71 }
72 }
73 Some(_) => auth_error("Authorization header must use Bearer scheme"),
74 None => auth_error("Missing Authorization header"),
75 }
76}
77
78/// Build a 401 error response
79fn auth_error(message: &str) -> Response {
80 let body = ErrorResponse::new("authentication_error", message);
81 (StatusCode::UNAUTHORIZED, Json(body)).into_response()
82}
83
84/// Whether `env_var` holds a usable API key.
85///
86/// The same rule [`require_auth`] applies: unset **or** empty means no key. One
87/// definition, so a caller cannot decide "configured" differently from the
88/// middleware that enforces it.
89#[must_use]
90pub fn api_key_configured(env_var: &str) -> bool {
91 matches!(env::var(env_var), Ok(key) if !key.is_empty())
92}
93
94/// The authentication posture a server resolved at startup.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum AuthMode {
97 /// A key is set, so every request is authenticated.
98 Enforced,
99 /// No key, but the bind is loopback-only — unauthenticated local development.
100 ///
101 /// A caller should say so at startup. Silence here is how a machine ends up
102 /// serving without a key and nobody noticing.
103 LoopbackDev,
104}
105
106/// A server was asked to serve a reachable interface with no key.
107///
108/// [`require_auth`] fails **open** when its key is unset — deliberately, so a
109/// developer can run without ceremony, and unchanged here because it is a
110/// documented contract with reverse dependencies this workspace cannot
111/// enumerate. That default is survivable on loopback and not on an interface
112/// something else can reach.
113///
114/// This is the other half: a startup check the binary opts into, because
115/// deciding to terminate a process belongs to `main`, not to a library. The
116/// middleware cannot make this call itself — it sees a request, never the bind
117/// address.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct InsecureBindError {
120 /// The non-loopback host the server was asked to bind.
121 pub host: String,
122}
123
124impl fmt::Display for InsecureBindError {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 write!(
127 f,
128 "refusing to start: nothing authenticates requests while binding non-loopback host \
129 '{}'. Arm this service's gate, or bind 127.0.0.1 for local development.",
130 self.host
131 )
132 }
133}
134
135impl Error for InsecureBindError {}
136
137/// Whether `host` names a loopback interface.
138///
139/// Accepts `localhost` in any case, `127.0.0.0/8`, `::1`, and `::1` in its
140/// bracketed form. **Everything else is non-loopback, including `0.0.0.0`, `::`
141/// and any name this cannot parse** — an unresolvable name is treated as
142/// reachable so the check fails closed rather than guessing in the caller's
143/// favour.
144#[must_use]
145pub fn is_loopback_host(host: &str) -> bool {
146 let trimmed = host.trim();
147 if trimmed.eq_ignore_ascii_case("localhost") {
148 return true;
149 }
150 let stripped = trimmed
151 .strip_prefix('[')
152 .and_then(|s| s.strip_suffix(']'))
153 .unwrap_or(trimmed);
154 stripped.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
155}
156
157/// Resolve the startup posture for a bind, or refuse it.
158///
159/// - gated → [`AuthMode::Enforced`], whatever the host
160/// - ungated, loopback host → [`AuthMode::LoopbackDev`]
161/// - ungated, reachable host → [`InsecureBindError`], and the server must not start
162///
163/// `gated` is **whether anything at all will authenticate requests on this
164/// bind** — not whether a shared key is set. That distinction is the whole
165/// point of the parameter. A service gated by Google ID tokens holds no key
166/// and is correctly secured; an earlier version of this function asked only
167/// `api_key_configured`, which would have refused to start photograveur,
168/// sciotte and the platform backend — the three services in the fleet that got
169/// their auth most right. Every Cloud Run container must bind `0.0.0.0`, so a
170/// check that reads a reachable bind as unsafe on its own is wrong about the
171/// entire deployed estate.
172///
173/// Compose it with whatever actually gates you:
174///
175/// ```rust,ignore
176/// // shared key
177/// resolve_startup_auth(&args.host, api_key_configured("CAGEUX_API_TOKEN"))?;
178/// // Google ID tokens — no key to check, the middleware is the gate
179/// resolve_startup_auth(&args.host, true)?;
180/// ```
181///
182/// Call this in `main` before binding, and propagate the error. Nothing here
183/// exits the process on the caller's behalf: terminating belongs to the binary.
184///
185/// # Errors
186///
187/// Returns [`InsecureBindError`] when nothing gates the service and `host` is
188/// not loopback.
189pub fn resolve_startup_auth(host: &str, gated: bool) -> Result<AuthMode, InsecureBindError> {
190 if gated {
191 Ok(AuthMode::Enforced)
192 } else if is_loopback_host(host) {
193 Ok(AuthMode::LoopbackDev)
194 } else {
195 Err(InsecureBindError {
196 host: host.to_owned(),
197 })
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use axum::body::Body;
205 use axum::routing::get;
206 use axum::{middleware, Router};
207 use http::Request as HttpRequest;
208 use http_body_util::BodyExt;
209 use serde_json::Value;
210 use tower::ServiceExt;
211
212 // Each test uses a unique env var to avoid races in parallel execution
213 async fn dummy_handler() -> &'static str {
214 "ok"
215 }
216
217 fn make_app(env_var: &'static str) -> Router {
218 Router::new()
219 .route("/test", get(dummy_handler))
220 .layer(middleware::from_fn(move |req, next| {
221 require_auth(env_var, req, next)
222 }))
223 }
224
225 #[tokio::test]
226 async fn no_env_allows_all_requests() {
227 const ENV: &str = "TRONC_AUTH_TEST_NO_ENV";
228 env::remove_var(ENV);
229 let app = make_app(ENV);
230 let req = HttpRequest::builder()
231 .uri("/test")
232 .body(Body::empty())
233 .expect("request"); // Safe: test assertion
234
235 let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
236 assert_eq!(resp.status(), 200);
237 }
238
239 #[tokio::test]
240 async fn empty_env_allows_all_requests() {
241 const ENV: &str = "TRONC_AUTH_TEST_EMPTY";
242 env::set_var(ENV, "");
243 let app = make_app(ENV);
244 let req = HttpRequest::builder()
245 .uri("/test")
246 .body(Body::empty())
247 .expect("request"); // Safe: test assertion
248
249 let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
250 assert_eq!(resp.status(), 200);
251 env::remove_var(ENV);
252 }
253
254 #[tokio::test]
255 async fn valid_bearer_token_passes() {
256 const ENV: &str = "TRONC_AUTH_TEST_VALID";
257 env::set_var(ENV, "secret-key-123");
258 let app = make_app(ENV);
259 let req = HttpRequest::builder()
260 .uri("/test")
261 .header("authorization", "Bearer secret-key-123")
262 .body(Body::empty())
263 .expect("request"); // Safe: test assertion
264
265 let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
266 assert_eq!(resp.status(), 200);
267 env::remove_var(ENV);
268 }
269
270 #[tokio::test]
271 async fn invalid_bearer_token_returns_401() {
272 const ENV: &str = "TRONC_AUTH_TEST_INVALID";
273 env::set_var(ENV, "secret-key-123");
274 let app = make_app(ENV);
275 let req = HttpRequest::builder()
276 .uri("/test")
277 .header("authorization", "Bearer wrong-key")
278 .body(Body::empty())
279 .expect("request"); // Safe: test assertion
280
281 let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
282 assert_eq!(resp.status(), 401);
283
284 let bytes = resp.into_body().collect().await.expect("body").to_bytes(); // Safe: test assertion
285 let json: Value = serde_json::from_slice(&bytes).expect("json"); // Safe: test assertion
286 assert_eq!(json["error"]["type"], "authentication_error");
287 env::remove_var(ENV);
288 }
289
290 #[tokio::test]
291 async fn missing_header_returns_401() {
292 const ENV: &str = "TRONC_AUTH_TEST_MISSING";
293 env::set_var(ENV, "secret-key-123");
294 let app = make_app(ENV);
295 let req = HttpRequest::builder()
296 .uri("/test")
297 .body(Body::empty())
298 .expect("request"); // Safe: test assertion
299
300 let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
301 assert_eq!(resp.status(), 401);
302 env::remove_var(ENV);
303 }
304
305 #[tokio::test]
306 async fn non_bearer_scheme_returns_401() {
307 const ENV: &str = "TRONC_AUTH_TEST_SCHEME";
308 env::set_var(ENV, "secret-key-123");
309 let app = make_app(ENV);
310 let req = HttpRequest::builder()
311 .uri("/test")
312 .header("authorization", "Basic dXNlcjpwYXNz")
313 .body(Body::empty())
314 .expect("request"); // Safe: test assertion
315
316 let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
317 assert_eq!(resp.status(), 401);
318
319 let bytes = resp.into_body().collect().await.expect("body").to_bytes(); // Safe: test assertion
320 let json: Value = serde_json::from_slice(&bytes).expect("json"); // Safe: test assertion
321 assert!(json["error"]["message"]
322 .as_str()
323 .expect("msg") // Safe: test assertion
324 .contains("Bearer"));
325 env::remove_var(ENV);
326 }
327
328 // ---- startup posture ----
329
330 #[test]
331 fn loopback_hosts_are_recognised() {
332 for host in [
333 "127.0.0.1",
334 "127.1.2.3",
335 "::1",
336 "[::1]",
337 "localhost",
338 "LocalHost",
339 ] {
340 assert!(is_loopback_host(host), "{host} should be loopback");
341 }
342 }
343
344 #[test]
345 fn anything_reachable_or_unparseable_is_not_loopback() {
346 // 0.0.0.0 and :: are the wildcards that bind every interface, and an
347 // unresolvable name is treated as reachable so the check fails closed
348 // rather than guessing in the caller's favour.
349 for host in ["0.0.0.0", "::", "192.168.1.10", "example.com", "", " "] {
350 assert!(
351 !is_loopback_host(host),
352 "{host:?} must not read as loopback"
353 );
354 }
355 }
356
357 #[test]
358 fn a_reachable_bind_with_nothing_gating_it_is_refused() {
359 let err = resolve_startup_auth("0.0.0.0", false)
360 .expect_err("a reachable bind with no gate must be refused"); // Safe: test assertion
361
362 assert_eq!(err.host, "0.0.0.0");
363 let msg = err.to_string();
364 assert!(msg.contains("refusing to start"), "message was {msg:?}");
365 assert!(
366 msg.contains("0.0.0.0"),
367 "the message must name the bind that was refused; message was {msg:?}"
368 );
369 }
370
371 #[test]
372 fn an_identity_gated_service_binds_a_reachable_host_holding_no_key() {
373 // The regression this pins: an earlier draft resolved posture from
374 // `api_key_configured` alone. Every Cloud Run container binds 0.0.0.0,
375 // and the three services in the fleet with the strongest auth —
376 // photograveur, sciotte, the platform backend — gate on Google ID
377 // tokens and hold no API key at all. Under the key-only rule each one
378 // refuses to start. `gated` is "something authenticates requests",
379 // whatever that something is.
380 const ENV: &str = "TRONC_AUTH_TEST_POSTURE_NO_KEY_HERE";
381 env::remove_var(ENV);
382 assert!(!api_key_configured(ENV));
383
384 assert_eq!(
385 resolve_startup_auth("0.0.0.0", true),
386 Ok(AuthMode::Enforced)
387 );
388 }
389
390 #[test]
391 fn loopback_without_a_gate_is_development_not_a_refusal() {
392 assert_eq!(
393 resolve_startup_auth("127.0.0.1", false),
394 Ok(AuthMode::LoopbackDev)
395 );
396 }
397
398 #[test]
399 fn a_gate_enforces_on_every_host() {
400 assert_eq!(
401 resolve_startup_auth("0.0.0.0", true),
402 Ok(AuthMode::Enforced)
403 );
404 assert_eq!(
405 resolve_startup_auth("127.0.0.1", true),
406 Ok(AuthMode::Enforced)
407 );
408 }
409
410 #[test]
411 fn an_empty_key_is_no_key_here_too() {
412 // require_auth treats unset and empty alike; a caller composing
413 // api_key_configured into resolve_startup_auth must agree, or a server
414 // boots believing it is Enforced while every request sails through.
415 const ENV: &str = "TRONC_AUTH_TEST_POSTURE_EMPTY";
416 env::set_var(ENV, "");
417 assert!(!api_key_configured(ENV));
418 assert!(resolve_startup_auth("0.0.0.0", api_key_configured(ENV)).is_err());
419 env::remove_var(ENV);
420 }
421}