Skip to main content

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.map(bearer_credential) {
64        Some(Some(token)) => {
65            if token.as_bytes().ct_eq(expected_key.as_bytes()).into() {
66                next.run(request).await
67            } else {
68                auth_error("Invalid API key")
69            }
70        }
71        Some(None) => auth_error("Authorization header must use Bearer scheme"),
72        None => auth_error("Missing Authorization header"),
73    }
74}
75
76/// The credential carried by an `Authorization` header value that uses the
77/// `Bearer` scheme, or `None` for any other scheme or an empty credential.
78///
79/// The scheme is matched case-insensitively — RFC 7235 §2.1 makes every
80/// auth-scheme case-insensitive, so `bearer x` and `BEARER x` present the same
81/// credential as `Bearer x` — and the one or more spaces separating it from the
82/// credential are consumed. Every bearer check in this crate reads the header
83/// through this function, so no two of them can disagree about which forms
84/// authenticate.
85#[must_use]
86pub fn bearer_credential(header_value: &str) -> Option<&str> {
87    let (scheme, credential) = header_value.split_once(' ')?;
88    if !scheme.eq_ignore_ascii_case("Bearer") {
89        return None;
90    }
91    let credential = credential.trim_start_matches(' ');
92    (!credential.is_empty()).then_some(credential)
93}
94
95/// Build a 401 error response
96fn auth_error(message: &str) -> Response {
97    let body = ErrorResponse::new("authentication_error", message);
98    (StatusCode::UNAUTHORIZED, Json(body)).into_response()
99}
100
101/// Whether `env_var` holds a usable API key.
102///
103/// The same rule [`require_auth`] applies: unset **or** empty means no key. One
104/// definition, so a caller cannot decide "configured" differently from the
105/// middleware that enforces it.
106#[must_use]
107pub fn api_key_configured(env_var: &str) -> bool {
108    matches!(env::var(env_var), Ok(key) if !key.is_empty())
109}
110
111/// The authentication posture a server resolved at startup.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum AuthMode {
114    /// A key is set, so every request is authenticated.
115    Enforced,
116    /// No key, but the bind is loopback-only — unauthenticated local development.
117    ///
118    /// A caller should say so at startup. Silence here is how a machine ends up
119    /// serving without a key and nobody noticing.
120    LoopbackDev,
121}
122
123/// A server was asked to serve a reachable interface with no key.
124///
125/// [`require_auth`] fails **open** when its key is unset — deliberately, so a
126/// developer can run without ceremony, and unchanged here because it is a
127/// documented contract with reverse dependencies this workspace cannot
128/// enumerate. That default is survivable on loopback and not on an interface
129/// something else can reach.
130///
131/// This is the other half: a startup check the binary opts into, because
132/// deciding to terminate a process belongs to `main`, not to a library. The
133/// middleware cannot make this call itself — it sees a request, never the bind
134/// address.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct InsecureBindError {
137    /// The non-loopback host the server was asked to bind.
138    pub host: String,
139}
140
141impl fmt::Display for InsecureBindError {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(
144            f,
145            "refusing to start: nothing authenticates requests while binding non-loopback host \
146             '{}'. Arm this service's gate, or bind 127.0.0.1 for local development.",
147            self.host
148        )
149    }
150}
151
152impl Error for InsecureBindError {}
153
154/// Whether `host` names a loopback interface.
155///
156/// Accepts `localhost` in any case, `127.0.0.0/8`, `::1`, and `::1` in its
157/// bracketed form. **Everything else is non-loopback, including `0.0.0.0`, `::`
158/// and any name this cannot parse** — an unresolvable name is treated as
159/// reachable so the check fails closed rather than guessing in the caller's
160/// favour.
161#[must_use]
162pub fn is_loopback_host(host: &str) -> bool {
163    let trimmed = host.trim();
164    if trimmed.eq_ignore_ascii_case("localhost") {
165        return true;
166    }
167    let stripped = trimmed.strip_circumfix('[', ']').unwrap_or(trimmed);
168    stripped.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
169}
170
171/// Resolve the startup posture for a bind, or refuse it.
172///
173/// - gated → [`AuthMode::Enforced`], whatever the host
174/// - ungated, loopback host → [`AuthMode::LoopbackDev`]
175/// - ungated, reachable host → [`InsecureBindError`], and the server must not start
176///
177/// `gated` is **whether anything at all will authenticate requests on this
178/// bind** — not whether a shared key is set. That distinction is the whole
179/// point of the parameter. A service gated by Google ID tokens holds no key
180/// and is correctly secured; an earlier version of this function asked only
181/// `api_key_configured`, which would have refused to start photograveur,
182/// sciotte and the platform backend — the three services in the fleet that got
183/// their auth most right. Every Cloud Run container must bind `0.0.0.0`, so a
184/// check that reads a reachable bind as unsafe on its own is wrong about the
185/// entire deployed estate.
186///
187/// Compose it with whatever actually gates you:
188///
189/// ```rust,ignore
190/// // shared key
191/// resolve_startup_auth(&args.host, api_key_configured("CAGEUX_API_TOKEN"))?;
192/// // Google ID tokens — no key to check, the middleware is the gate
193/// resolve_startup_auth(&args.host, true)?;
194/// ```
195///
196/// Call this in `main` before binding, and propagate the error. Nothing here
197/// exits the process on the caller's behalf: terminating belongs to the binary.
198///
199/// # Errors
200///
201/// Returns [`InsecureBindError`] when nothing gates the service and `host` is
202/// not loopback.
203pub fn resolve_startup_auth(host: &str, gated: bool) -> Result<AuthMode, InsecureBindError> {
204    if gated {
205        Ok(AuthMode::Enforced)
206    } else if is_loopback_host(host) {
207        Ok(AuthMode::LoopbackDev)
208    } else {
209        Err(InsecureBindError {
210            host: host.to_owned(),
211        })
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use axum::body::Body;
219    use axum::routing::get;
220    use axum::{middleware, Router};
221    use http::Request as HttpRequest;
222    use http_body_util::BodyExt;
223    use serde_json::Value;
224    use tower::ServiceExt;
225
226    // Each test uses a unique env var to avoid races in parallel execution
227    async fn dummy_handler() -> &'static str {
228        "ok"
229    }
230
231    fn make_app(env_var: &'static str) -> Router {
232        Router::new()
233            .route("/test", get(dummy_handler))
234            .layer(middleware::from_fn(move |req, next| {
235                require_auth(env_var, req, next)
236            }))
237    }
238
239    #[tokio::test]
240    async fn no_env_allows_all_requests() {
241        const ENV: &str = "TRONC_AUTH_TEST_NO_ENV";
242        env::remove_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    }
252
253    #[tokio::test]
254    async fn empty_env_allows_all_requests() {
255        const ENV: &str = "TRONC_AUTH_TEST_EMPTY";
256        env::set_var(ENV, "");
257        let app = make_app(ENV);
258        let req = HttpRequest::builder()
259            .uri("/test")
260            .body(Body::empty())
261            .expect("request"); // Safe: test assertion
262
263        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
264        assert_eq!(resp.status(), 200);
265        env::remove_var(ENV);
266    }
267
268    #[tokio::test]
269    async fn valid_bearer_token_passes() {
270        const ENV: &str = "TRONC_AUTH_TEST_VALID";
271        env::set_var(ENV, "secret-key-123");
272        let app = make_app(ENV);
273        let req = HttpRequest::builder()
274            .uri("/test")
275            .header("authorization", "Bearer secret-key-123")
276            .body(Body::empty())
277            .expect("request"); // Safe: test assertion
278
279        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
280        assert_eq!(resp.status(), 200);
281        env::remove_var(ENV);
282    }
283
284    #[tokio::test]
285    async fn invalid_bearer_token_returns_401() {
286        const ENV: &str = "TRONC_AUTH_TEST_INVALID";
287        env::set_var(ENV, "secret-key-123");
288        let app = make_app(ENV);
289        let req = HttpRequest::builder()
290            .uri("/test")
291            .header("authorization", "Bearer wrong-key")
292            .body(Body::empty())
293            .expect("request"); // Safe: test assertion
294
295        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
296        assert_eq!(resp.status(), 401);
297
298        let bytes = resp.into_body().collect().await.expect("body").to_bytes(); // Safe: test assertion
299        let json: Value = serde_json::from_slice(&bytes).expect("json"); // Safe: test assertion
300        assert_eq!(json["error"]["type"], "authentication_error");
301        env::remove_var(ENV);
302    }
303
304    #[tokio::test]
305    async fn missing_header_returns_401() {
306        const ENV: &str = "TRONC_AUTH_TEST_MISSING";
307        env::set_var(ENV, "secret-key-123");
308        let app = make_app(ENV);
309        let req = HttpRequest::builder()
310            .uri("/test")
311            .body(Body::empty())
312            .expect("request"); // Safe: test assertion
313
314        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
315        assert_eq!(resp.status(), 401);
316        env::remove_var(ENV);
317    }
318
319    #[tokio::test]
320    async fn non_bearer_scheme_returns_401() {
321        const ENV: &str = "TRONC_AUTH_TEST_SCHEME";
322        env::set_var(ENV, "secret-key-123");
323        let app = make_app(ENV);
324        let req = HttpRequest::builder()
325            .uri("/test")
326            .header("authorization", "Basic dXNlcjpwYXNz")
327            .body(Body::empty())
328            .expect("request"); // Safe: test assertion
329
330        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
331        assert_eq!(resp.status(), 401);
332
333        let bytes = resp.into_body().collect().await.expect("body").to_bytes(); // Safe: test assertion
334        let json: Value = serde_json::from_slice(&bytes).expect("json"); // Safe: test assertion
335        assert!(json["error"]["message"]
336            .as_str()
337            .expect("msg") // Safe: test assertion
338            .contains("Bearer"));
339        env::remove_var(ENV);
340    }
341
342    /// RFC 7235 §2.1: the auth-scheme is case-insensitive. A client sending
343    /// `bearer` in lower case holds the right key and used to get a 401.
344    #[tokio::test]
345    async fn lowercase_bearer_scheme_passes() {
346        const ENV: &str = "TRONC_AUTH_TEST_LOWERCASE_SCHEME";
347        env::set_var(ENV, "secret-key-123");
348        let app = make_app(ENV);
349        let req = HttpRequest::builder()
350            .uri("/test")
351            .header("authorization", "bearer secret-key-123")
352            .body(Body::empty())
353            .expect("request"); // Safe: test assertion
354
355        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
356        assert_eq!(resp.status(), 200);
357        env::remove_var(ENV);
358    }
359
360    #[test]
361    fn bearer_credential_reads_the_scheme_in_any_case() {
362        for header in [
363            "Bearer tok-1",
364            "bearer tok-1",
365            "BEARER tok-1",
366            "BeArEr tok-1",
367            "Bearer   tok-1",
368        ] {
369            assert_eq!(
370                bearer_credential(header),
371                Some("tok-1"),
372                "{header:?} carries the credential tok-1"
373            );
374        }
375    }
376
377    #[test]
378    fn bearer_credential_refuses_other_schemes_and_empty_credentials() {
379        for header in [
380            "Basic dXNlcjpwYXNz",
381            "Bearertok-1",
382            "Bearer",
383            "Bearer ",
384            "Bearer    ",
385            "tok-1",
386            "",
387        ] {
388            assert_eq!(
389                bearer_credential(header),
390                None,
391                "{header:?} must not read as a bearer credential"
392            );
393        }
394    }
395
396    // ---- startup posture ----
397
398    #[test]
399    fn loopback_hosts_are_recognised() {
400        for host in [
401            "127.0.0.1",
402            "127.1.2.3",
403            "::1",
404            "[::1]",
405            "localhost",
406            "LocalHost",
407        ] {
408            assert!(is_loopback_host(host), "{host} should be loopback");
409        }
410    }
411
412    #[test]
413    fn anything_reachable_or_unparseable_is_not_loopback() {
414        // 0.0.0.0 and :: are the wildcards that bind every interface, and an
415        // unresolvable name is treated as reachable so the check fails closed
416        // rather than guessing in the caller's favour.
417        for host in ["0.0.0.0", "::", "192.168.1.10", "example.com", "", "   "] {
418            assert!(
419                !is_loopback_host(host),
420                "{host:?} must not read as loopback"
421            );
422        }
423    }
424
425    #[test]
426    fn a_reachable_bind_with_nothing_gating_it_is_refused() {
427        let err = resolve_startup_auth("0.0.0.0", false)
428            .expect_err("a reachable bind with no gate must be refused"); // Safe: test assertion
429
430        assert_eq!(err.host, "0.0.0.0");
431        let msg = err.to_string();
432        assert!(msg.contains("refusing to start"), "message was {msg:?}");
433        assert!(
434            msg.contains("0.0.0.0"),
435            "the message must name the bind that was refused; message was {msg:?}"
436        );
437    }
438
439    #[test]
440    fn an_identity_gated_service_binds_a_reachable_host_holding_no_key() {
441        // The regression this pins: an earlier draft resolved posture from
442        // `api_key_configured` alone. Every Cloud Run container binds 0.0.0.0,
443        // and the three services in the fleet with the strongest auth —
444        // photograveur, sciotte, the platform backend — gate on Google ID
445        // tokens and hold no API key at all. Under the key-only rule each one
446        // refuses to start. `gated` is "something authenticates requests",
447        // whatever that something is.
448        const ENV: &str = "TRONC_AUTH_TEST_POSTURE_NO_KEY_HERE";
449        env::remove_var(ENV);
450        assert!(!api_key_configured(ENV));
451
452        assert_eq!(
453            resolve_startup_auth("0.0.0.0", true),
454            Ok(AuthMode::Enforced)
455        );
456    }
457
458    #[test]
459    fn loopback_without_a_gate_is_development_not_a_refusal() {
460        assert_eq!(
461            resolve_startup_auth("127.0.0.1", false),
462            Ok(AuthMode::LoopbackDev)
463        );
464    }
465
466    #[test]
467    fn a_gate_enforces_on_every_host() {
468        assert_eq!(
469            resolve_startup_auth("0.0.0.0", true),
470            Ok(AuthMode::Enforced)
471        );
472        assert_eq!(
473            resolve_startup_auth("127.0.0.1", true),
474            Ok(AuthMode::Enforced)
475        );
476    }
477
478    #[test]
479    fn an_empty_key_is_no_key_here_too() {
480        // require_auth treats unset and empty alike; a caller composing
481        // api_key_configured into resolve_startup_auth must agree, or a server
482        // boots believing it is Enforced while every request sails through.
483        const ENV: &str = "TRONC_AUTH_TEST_POSTURE_EMPTY";
484        env::set_var(ENV, "");
485        assert!(!api_key_configured(ENV));
486        assert!(resolve_startup_auth("0.0.0.0", api_key_configured(ENV)).is_err());
487        env::remove_var(ENV);
488    }
489}