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 {
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}