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. [`startup_auth`] is that check
13//! for the common case, a service gated by one shared key, with the posture
14//! logged: every satellite used to carry its own copy of those ten lines.
15//!
16//! **Which mechanism?** This crate ships six, and the choice is made by what
17//! the caller can present, per route — not by which one has no feature flag.
18//! The table is in the crate README under "Choosing an auth mechanism"; read it
19//! before reaching for a shared bearer key, which is the wrong answer whenever
20//! the caller is another Google workload, a browser session, or a webhook.
21
22use std::env;
23use std::error::Error;
24use std::fmt;
25use std::net::IpAddr;
26
27use axum::extract::Request;
28use axum::http::StatusCode;
29use axum::middleware::Next;
30use axum::response::{IntoResponse, Response};
31use axum::Json;
32use subtle::ConstantTimeEq;
33use tracing::{info, warn};
34
35use crate::error::ErrorResponse;
36
37/// Create an Axum middleware function that validates bearer tokens
38///
39/// Reads the API key from the given environment variable on every request
40/// to allow runtime key rotation without restarting. If the variable is not
41/// set, empty or blank, all requests pass through (development mode).
42///
43/// # Usage
44///
45/// ```rust,ignore
46/// use axum::middleware;
47/// use dravr_tronc::server::auth::require_auth;
48///
49/// let app = Router::new()
50///     .route("/api/endpoint", get(handler))
51///     .layer(middleware::from_fn(|req, next| {
52///         require_auth("MY_API_KEY_ENV", req, next)
53///     }));
54/// ```
55pub async fn require_auth(env_var: &str, request: Request, next: Next) -> Response {
56    let Some(expected_key) = configured_key(env_var) else {
57        return next.run(request).await;
58    };
59
60    let auth_header = request
61        .headers()
62        .get("authorization")
63        .and_then(|v| v.to_str().ok());
64
65    match auth_header.map(bearer_credential) {
66        Some(Some(token)) => {
67            if token.as_bytes().ct_eq(expected_key.as_bytes()).into() {
68                next.run(request).await
69            } else {
70                auth_error("Invalid API key")
71            }
72        }
73        Some(None) => auth_error("Authorization header must use Bearer scheme"),
74        None => auth_error("Missing Authorization header"),
75    }
76}
77
78/// The credential carried by an `Authorization` header value that uses the
79/// `Bearer` scheme, or `None` for any other scheme or an empty credential.
80///
81/// The scheme is matched case-insensitively — RFC 7235 §2.1 makes every
82/// auth-scheme case-insensitive, so `bearer x` and `BEARER x` present the same
83/// credential as `Bearer x` — and the one or more spaces separating it from the
84/// credential are consumed. Every bearer check in this crate reads the header
85/// through this function, so no two of them can disagree about which forms
86/// authenticate.
87#[must_use]
88pub fn bearer_credential(header_value: &str) -> Option<&str> {
89    let (scheme, credential) = header_value.split_once(' ')?;
90    if !scheme.eq_ignore_ascii_case("Bearer") {
91        return None;
92    }
93    let credential = credential.trim_start_matches(' ');
94    (!credential.is_empty()).then_some(credential)
95}
96
97/// Build a 401 error response
98fn auth_error(message: &str) -> Response {
99    let body = ErrorResponse::new("authentication_error", message);
100    (StatusCode::UNAUTHORIZED, Json(body)).into_response()
101}
102
103/// The API key `env_var` holds with surrounding whitespace trimmed, or `None`
104/// when it is unset or blank.
105///
106/// A blank value (`" "`, or a lone newline) is not a secret, so it counts as
107/// no key at all. A key written with `echo` arrives as `"secret\n"`, and the
108/// newline is not part of it: a header can never carry one, so comparing the
109/// untrimmed value would refuse every caller of a server that booted
110/// enforced. [`require_auth`], [`api_key_configured`] and
111/// [`ApiKeyAuthHook`](crate::mcp::auth::ApiKeyAuthHook) all read the key
112/// through here, so a server cannot boot believing it is enforced under a key
113/// its gate reads otherwise.
114pub(crate) fn configured_key(env_var: &str) -> Option<String> {
115    let key = env::var(env_var).ok()?;
116    let trimmed = key.trim();
117    (!trimmed.is_empty()).then(|| trimmed.to_owned())
118}
119
120/// Whether `env_var` holds a usable API key.
121///
122/// The same rule [`require_auth`] applies: unset, empty **or** blank after
123/// trimming means no key. One definition, so a caller cannot decide
124/// "configured" differently from the middleware that enforces it.
125#[must_use]
126pub fn api_key_configured(env_var: &str) -> bool {
127    configured_key(env_var).is_some()
128}
129
130/// The authentication posture a server resolved at startup.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum AuthMode {
133    /// A key is set, so every request is authenticated.
134    Enforced,
135    /// No key, but the bind is loopback-only — unauthenticated local development.
136    ///
137    /// A caller should say so at startup. Silence here is how a machine ends up
138    /// serving without a key and nobody noticing.
139    LoopbackDev,
140}
141
142/// A server was asked to serve a reachable interface with no key.
143///
144/// [`require_auth`] fails **open** when its key is unset — deliberately, so a
145/// developer can run without ceremony, and unchanged here because it is a
146/// documented contract with reverse dependencies this workspace cannot
147/// enumerate. That default is survivable on loopback and not on an interface
148/// something else can reach.
149///
150/// This is the other half: a startup check the binary opts into, because
151/// deciding to terminate a process belongs to `main`, not to a library. The
152/// middleware cannot make this call itself — it sees a request, never the bind
153/// address.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct InsecureBindError {
156    /// The non-loopback host the server was asked to bind.
157    pub host: String,
158    /// The environment variable that arms the gate, when the gate is a shared
159    /// key, so the refusal can say what to set. `None` when the gate is
160    /// something else (an [`AuthHook`](crate::mcp::auth::AuthHook), Google ID
161    /// tokens) and there is no single variable to name.
162    pub gate: Option<String>,
163}
164
165impl InsecureBindError {
166    /// A refusal of `host` for a service whose gate is not a single variable.
167    #[must_use]
168    pub fn new(host: impl Into<String>) -> Self {
169        Self {
170            host: host.into(),
171            gate: None,
172        }
173    }
174}
175
176impl fmt::Display for InsecureBindError {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        write!(
179            f,
180            "refusing to start: nothing authenticates requests while binding non-loopback host \
181             '{}'. ",
182            self.host
183        )?;
184        match &self.gate {
185            Some(var) => write!(
186                f,
187                "Set {var} (every request must then carry it as a bearer token), or bind \
188                 127.0.0.1 for local development."
189            ),
190            None => {
191                f.write_str("Arm this service's gate, or bind 127.0.0.1 for local development.")
192            }
193        }
194    }
195}
196
197impl Error for InsecureBindError {}
198
199/// Whether `host` names a loopback interface.
200///
201/// Accepts `localhost` in any case, `127.0.0.0/8`, `::1`, and `::1` in its
202/// bracketed form. **Everything else is non-loopback, including `0.0.0.0`, `::`
203/// and any other name** — a name this cannot classify from its text is treated
204/// as reachable so the check fails closed rather than guessing in the caller's
205/// favour.
206///
207/// `localhost` counts as loopback because RFC 6761 §6.3 reserves it for the
208/// loopback interface, and browsers hard-wire it there: an origin of
209/// `http://localhost:3000` is a page on this machine. A name is still only a
210/// name, though. Binding one goes through the system resolver, which answers
211/// `localhost` with `127.0.0.1`, `::1`, or both — and, on a host whose resolver
212/// is configured otherwise, with anything at all. So this is a judgement of the
213/// text, made before any socket exists; [`crate::mcp::transport::http::serve`]
214/// goes further and judges every address the name resolves to before it binds.
215/// A binary that binds its own listener and wants the same certainty checks
216/// `local_addr()` of what it bound.
217#[must_use]
218pub fn is_loopback_host(host: &str) -> bool {
219    let trimmed = host.trim();
220    if trimmed.eq_ignore_ascii_case("localhost") {
221        return true;
222    }
223    let stripped = trimmed.strip_circumfix('[', ']').unwrap_or(trimmed);
224    stripped.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
225}
226
227/// Resolve the startup posture for a bind, or refuse it.
228///
229/// - gated → [`AuthMode::Enforced`], whatever the host
230/// - ungated, loopback host → [`AuthMode::LoopbackDev`]
231/// - ungated, reachable host → [`InsecureBindError`], and the server must not start
232///
233/// `gated` is **whether anything at all will authenticate requests on this
234/// bind** — not whether a shared key is set. That distinction is the whole
235/// point of the parameter. A service gated by Google ID tokens holds no key
236/// and is correctly secured; an earlier version of this function asked only
237/// `api_key_configured`, which would have refused to start photograveur,
238/// sciotte and the platform backend — the three services in the fleet that got
239/// their auth most right. Every Cloud Run container must bind `0.0.0.0`, so a
240/// check that reads a reachable bind as unsafe on its own is wrong about the
241/// entire deployed estate.
242///
243/// Compose it with whatever actually gates you:
244///
245/// ```rust,ignore
246/// // shared key
247/// resolve_startup_auth(&args.host, api_key_configured("CAGEUX_API_TOKEN"))?;
248/// // Google ID tokens — no key to check, the middleware is the gate
249/// resolve_startup_auth(&args.host, true)?;
250/// ```
251///
252/// Call this in `main` before binding, and propagate the error. Nothing here
253/// exits the process on the caller's behalf: terminating belongs to the binary.
254///
255/// # Errors
256///
257/// Returns [`InsecureBindError`] when nothing gates the service and `host` is
258/// not loopback.
259pub fn resolve_startup_auth(host: &str, gated: bool) -> Result<AuthMode, InsecureBindError> {
260    if gated {
261        Ok(AuthMode::Enforced)
262    } else if is_loopback_host(host) {
263        Ok(AuthMode::LoopbackDev)
264    } else {
265        Err(InsecureBindError::new(host))
266    }
267}
268
269/// Resolve the startup posture of a service gated by the shared bearer key in
270/// `env_var`, and say it: INFO when the key is set, WARN when the service is
271/// serving unauthenticated on loopback.
272///
273/// This is [`resolve_startup_auth`] composed with [`api_key_configured`], the
274/// form every key-gated service in the fleet wrote out by hand. The key it
275/// checks is the one [`require_auth`] and
276/// [`ApiKeyAuthHook`](crate::mcp::auth::ApiKeyAuthHook) enforce, so posture
277/// and enforcement read the same variable by the same rule.
278///
279/// A service gated by something other than one key (Google ID tokens, a
280/// session) calls [`resolve_startup_auth`] with its own `gated` instead.
281///
282/// # Errors
283///
284/// [`InsecureBindError`] naming `env_var` when the key is unset or empty and
285/// `host` is not loopback: nothing would authenticate requests, so the server
286/// must not start.
287pub fn startup_auth(env_var: &str, host: &str) -> Result<AuthMode, InsecureBindError> {
288    let mode = resolve_startup_auth(host, api_key_configured(env_var)).map_err(|refused| {
289        InsecureBindError {
290            gate: Some(env_var.to_owned()),
291            ..refused
292        }
293    })?;
294    match mode {
295        AuthMode::Enforced => info!(
296            gate = env_var,
297            "{env_var} set — every request must carry it as a bearer token"
298        ),
299        AuthMode::LoopbackDev => warn!(
300            gate = env_var,
301            host, "{env_var} unset — serving UNAUTHENTICATED on loopback for local development"
302        ),
303    }
304    Ok(mode)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use axum::body::Body;
311    use axum::routing::get;
312    use axum::{middleware, Router};
313    use http::Request as HttpRequest;
314    use http_body_util::BodyExt;
315    use serde_json::Value;
316    use tower::ServiceExt;
317
318    // Each test uses a unique env var to avoid races in parallel execution
319    async fn dummy_handler() -> &'static str {
320        "ok"
321    }
322
323    fn make_app(env_var: &'static str) -> Router {
324        Router::new()
325            .route("/test", get(dummy_handler))
326            .layer(middleware::from_fn(move |req, next| {
327                require_auth(env_var, req, next)
328            }))
329    }
330
331    #[tokio::test]
332    async fn no_env_allows_all_requests() {
333        const ENV: &str = "TRONC_AUTH_TEST_NO_ENV";
334        env::remove_var(ENV);
335        let app = make_app(ENV);
336        let req = HttpRequest::builder()
337            .uri("/test")
338            .body(Body::empty())
339            .expect("request"); // Safe: test assertion
340
341        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
342        assert_eq!(resp.status(), 200);
343    }
344
345    #[tokio::test]
346    async fn empty_env_allows_all_requests() {
347        const ENV: &str = "TRONC_AUTH_TEST_EMPTY";
348        env::set_var(ENV, "");
349        let app = make_app(ENV);
350        let req = HttpRequest::builder()
351            .uri("/test")
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    #[tokio::test]
361    async fn blank_env_is_treated_as_unset() {
362        // The middleware and api_key_configured read the key one way: a
363        // blank value is no key, so the server's posture and its gate agree.
364        const ENV: &str = "TRONC_AUTH_TEST_BLANK";
365        env::set_var(ENV, " \n");
366        let app = make_app(ENV);
367        let req = HttpRequest::builder()
368            .uri("/test")
369            .body(Body::empty())
370            .expect("request"); // Safe: test assertion
371
372        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
373        assert_eq!(resp.status(), 200);
374        assert!(!api_key_configured(ENV));
375        env::remove_var(ENV);
376    }
377
378    /// A key written with `echo` carries a trailing newline that no header
379    /// can. The server boots enforced under it, so the middleware must
380    /// compare the key without it, or every caller is locked out.
381    #[tokio::test]
382    async fn a_key_with_a_trailing_newline_is_compared_trimmed() {
383        const ENV: &str = "TRONC_AUTH_TEST_TRAILING_NEWLINE";
384        env::set_var(ENV, "abc\n");
385        assert!(api_key_configured(ENV));
386        assert_eq!(configured_key(ENV).as_deref(), Some("abc"));
387
388        let mut statuses = Vec::new();
389        for token in ["abc", "abd"] {
390            let req = HttpRequest::builder()
391                .uri("/test")
392                .header("authorization", format!("Bearer {token}"))
393                .body(Body::empty())
394                .expect("request"); // Safe: test assertion
395            let resp = make_app(ENV).oneshot(req).await.expect("response"); // Safe: test assertion
396            statuses.push(resp.status().as_u16());
397        }
398        env::remove_var(ENV);
399
400        assert_eq!(statuses, [200, 401]);
401    }
402
403    #[tokio::test]
404    async fn valid_bearer_token_passes() {
405        const ENV: &str = "TRONC_AUTH_TEST_VALID";
406        env::set_var(ENV, "secret-key-123");
407        let app = make_app(ENV);
408        let req = HttpRequest::builder()
409            .uri("/test")
410            .header("authorization", "Bearer secret-key-123")
411            .body(Body::empty())
412            .expect("request"); // Safe: test assertion
413
414        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
415        assert_eq!(resp.status(), 200);
416        env::remove_var(ENV);
417    }
418
419    #[tokio::test]
420    async fn invalid_bearer_token_returns_401() {
421        const ENV: &str = "TRONC_AUTH_TEST_INVALID";
422        env::set_var(ENV, "secret-key-123");
423        let app = make_app(ENV);
424        let req = HttpRequest::builder()
425            .uri("/test")
426            .header("authorization", "Bearer wrong-key")
427            .body(Body::empty())
428            .expect("request"); // Safe: test assertion
429
430        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
431        assert_eq!(resp.status(), 401);
432
433        let bytes = resp.into_body().collect().await.expect("body").to_bytes(); // Safe: test assertion
434        let json: Value = serde_json::from_slice(&bytes).expect("json"); // Safe: test assertion
435        assert_eq!(json["error"]["type"], "authentication_error");
436        env::remove_var(ENV);
437    }
438
439    #[tokio::test]
440    async fn missing_header_returns_401() {
441        const ENV: &str = "TRONC_AUTH_TEST_MISSING";
442        env::set_var(ENV, "secret-key-123");
443        let app = make_app(ENV);
444        let req = HttpRequest::builder()
445            .uri("/test")
446            .body(Body::empty())
447            .expect("request"); // Safe: test assertion
448
449        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
450        assert_eq!(resp.status(), 401);
451        env::remove_var(ENV);
452    }
453
454    #[tokio::test]
455    async fn non_bearer_scheme_returns_401() {
456        const ENV: &str = "TRONC_AUTH_TEST_SCHEME";
457        env::set_var(ENV, "secret-key-123");
458        let app = make_app(ENV);
459        let req = HttpRequest::builder()
460            .uri("/test")
461            .header("authorization", "Basic dXNlcjpwYXNz")
462            .body(Body::empty())
463            .expect("request"); // Safe: test assertion
464
465        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
466        assert_eq!(resp.status(), 401);
467
468        let bytes = resp.into_body().collect().await.expect("body").to_bytes(); // Safe: test assertion
469        let json: Value = serde_json::from_slice(&bytes).expect("json"); // Safe: test assertion
470        assert!(json["error"]["message"]
471            .as_str()
472            .expect("msg") // Safe: test assertion
473            .contains("Bearer"));
474        env::remove_var(ENV);
475    }
476
477    /// RFC 7235 §2.1: the auth-scheme is case-insensitive. A client sending
478    /// `bearer` in lower case holds the right key and used to get a 401.
479    #[tokio::test]
480    async fn lowercase_bearer_scheme_passes() {
481        const ENV: &str = "TRONC_AUTH_TEST_LOWERCASE_SCHEME";
482        env::set_var(ENV, "secret-key-123");
483        let app = make_app(ENV);
484        let req = HttpRequest::builder()
485            .uri("/test")
486            .header("authorization", "bearer secret-key-123")
487            .body(Body::empty())
488            .expect("request"); // Safe: test assertion
489
490        let resp = app.oneshot(req).await.expect("response"); // Safe: test assertion
491        assert_eq!(resp.status(), 200);
492        env::remove_var(ENV);
493    }
494
495    #[test]
496    fn bearer_credential_reads_the_scheme_in_any_case() {
497        for header in [
498            "Bearer tok-1",
499            "bearer tok-1",
500            "BEARER tok-1",
501            "BeArEr tok-1",
502            "Bearer   tok-1",
503        ] {
504            assert_eq!(
505                bearer_credential(header),
506                Some("tok-1"),
507                "{header:?} carries the credential tok-1"
508            );
509        }
510    }
511
512    #[test]
513    fn bearer_credential_refuses_other_schemes_and_empty_credentials() {
514        for header in [
515            "Basic dXNlcjpwYXNz",
516            "Bearertok-1",
517            "Bearer",
518            "Bearer ",
519            "Bearer    ",
520            "tok-1",
521            "",
522        ] {
523            assert_eq!(
524                bearer_credential(header),
525                None,
526                "{header:?} must not read as a bearer credential"
527            );
528        }
529    }
530
531    // ---- startup posture ----
532
533    #[test]
534    fn loopback_hosts_are_recognised() {
535        for host in [
536            "127.0.0.1",
537            "127.1.2.3",
538            "::1",
539            "[::1]",
540            "localhost",
541            "LocalHost",
542        ] {
543            assert!(is_loopback_host(host), "{host} should be loopback");
544        }
545    }
546
547    #[test]
548    fn anything_reachable_or_unparseable_is_not_loopback() {
549        // 0.0.0.0 and :: are the wildcards that bind every interface, and an
550        // unresolvable name is treated as reachable so the check fails closed
551        // rather than guessing in the caller's favour.
552        for host in ["0.0.0.0", "::", "192.168.1.10", "example.com", "", "   "] {
553            assert!(
554                !is_loopback_host(host),
555                "{host:?} must not read as loopback"
556            );
557        }
558    }
559
560    #[test]
561    fn a_reachable_bind_with_nothing_gating_it_is_refused() {
562        let err = resolve_startup_auth("0.0.0.0", false)
563            .expect_err("a reachable bind with no gate must be refused"); // Safe: test assertion
564
565        assert_eq!(err.host, "0.0.0.0");
566        let msg = err.to_string();
567        assert!(msg.contains("refusing to start"), "message was {msg:?}");
568        assert!(
569            msg.contains("0.0.0.0"),
570            "the message must name the bind that was refused; message was {msg:?}"
571        );
572    }
573
574    #[test]
575    fn an_identity_gated_service_binds_a_reachable_host_holding_no_key() {
576        // The regression this pins: an earlier draft resolved posture from
577        // `api_key_configured` alone. Every Cloud Run container binds 0.0.0.0,
578        // and the three services in the fleet with the strongest auth —
579        // photograveur, sciotte, the platform backend — gate on Google ID
580        // tokens and hold no API key at all. Under the key-only rule each one
581        // refuses to start. `gated` is "something authenticates requests",
582        // whatever that something is.
583        const ENV: &str = "TRONC_AUTH_TEST_POSTURE_NO_KEY_HERE";
584        env::remove_var(ENV);
585        assert!(!api_key_configured(ENV));
586
587        assert_eq!(
588            resolve_startup_auth("0.0.0.0", true),
589            Ok(AuthMode::Enforced)
590        );
591    }
592
593    #[test]
594    fn loopback_without_a_gate_is_development_not_a_refusal() {
595        assert_eq!(
596            resolve_startup_auth("127.0.0.1", false),
597            Ok(AuthMode::LoopbackDev)
598        );
599    }
600
601    #[test]
602    fn a_gate_enforces_on_every_host() {
603        assert_eq!(
604            resolve_startup_auth("0.0.0.0", true),
605            Ok(AuthMode::Enforced)
606        );
607        assert_eq!(
608            resolve_startup_auth("127.0.0.1", true),
609            Ok(AuthMode::Enforced)
610        );
611    }
612
613    // ---- startup_auth: the key-gated posture, logged ----
614
615    /// Everything a subscriber wrote while `f` ran, as text.
616    fn captured_logs(f: impl FnOnce()) -> String {
617        use std::io;
618        use std::sync::{Arc, Mutex, PoisonError};
619        use tracing::subscriber::with_default;
620        use tracing_subscriber::fmt::MakeWriter;
621
622        #[derive(Clone, Default)]
623        struct Buffer(Arc<Mutex<Vec<u8>>>);
624
625        impl io::Write for Buffer {
626            fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
627                self.0
628                    .lock()
629                    .unwrap_or_else(PoisonError::into_inner)
630                    .extend_from_slice(bytes);
631                Ok(bytes.len())
632            }
633
634            fn flush(&mut self) -> io::Result<()> {
635                Ok(())
636            }
637        }
638
639        impl<'a> MakeWriter<'a> for Buffer {
640            type Writer = Self;
641
642            fn make_writer(&'a self) -> Self::Writer {
643                self.clone()
644            }
645        }
646
647        let buffer = Buffer::default();
648        let subscriber = tracing_subscriber::fmt()
649            .with_writer(buffer.clone())
650            .with_ansi(false)
651            .finish();
652        with_default(subscriber, f);
653        let bytes = buffer
654            .0
655            .lock()
656            .unwrap_or_else(PoisonError::into_inner)
657            .clone();
658        String::from_utf8(bytes).expect("utf-8 log") // Safe: test assertion
659    }
660
661    #[test]
662    fn startup_auth_refuses_a_reachable_bind_and_names_the_key_to_set() {
663        const ENV: &str = "TRONC_STARTUP_AUTH_TEST_REFUSED";
664        env::remove_var(ENV);
665
666        let err =
667            startup_auth(ENV, "0.0.0.0").expect_err("no key on a reachable bind must be refused"); // Safe: test assertion
668
669        assert_eq!(err.host, "0.0.0.0");
670        assert_eq!(err.gate.as_deref(), Some(ENV));
671        let msg = err.to_string();
672        assert!(
673            msg.contains(&format!("Set {ENV}")),
674            "the refusal must name the variable that arms the gate; message was {msg:?}"
675        );
676    }
677
678    #[test]
679    fn startup_auth_enforces_when_the_key_is_set_and_says_so() {
680        const ENV: &str = "TRONC_STARTUP_AUTH_TEST_ENFORCED";
681        env::set_var(ENV, "k-123");
682
683        let mut mode = None;
684        let logs = captured_logs(|| mode = Some(startup_auth(ENV, "0.0.0.0")));
685
686        assert_eq!(mode, Some(Ok(AuthMode::Enforced)));
687        assert!(logs.contains(" INFO "), "logs were {logs:?}");
688        assert!(
689            logs.contains(&format!("{ENV} set")),
690            "the INFO line must name the key; logs were {logs:?}"
691        );
692        assert!(
693            !logs.contains("k-123"),
694            "the key's value must never be logged"
695        );
696        env::remove_var(ENV);
697    }
698
699    #[test]
700    fn startup_auth_warns_when_loopback_serves_unauthenticated() {
701        const ENV: &str = "TRONC_STARTUP_AUTH_TEST_LOOPBACK";
702        env::set_var(ENV, "");
703
704        let mut mode = None;
705        let logs = captured_logs(|| mode = Some(startup_auth(ENV, "localhost")));
706
707        assert_eq!(mode, Some(Ok(AuthMode::LoopbackDev)));
708        assert!(logs.contains(" WARN "), "logs were {logs:?}");
709        assert!(
710            logs.contains(&format!("{ENV} unset")) && logs.contains("UNAUTHENTICATED"),
711            "the WARN line must say which key is missing; logs were {logs:?}"
712        );
713        env::remove_var(ENV);
714    }
715
716    #[test]
717    fn a_refusal_with_no_single_gate_asks_for_the_gate_generically() {
718        let msg = resolve_startup_auth("0.0.0.0", false)
719            .expect_err("refused") // Safe: test assertion
720            .to_string();
721        assert!(
722            msg.contains("Arm this service's gate"),
723            "message was {msg:?}"
724        );
725    }
726
727    #[test]
728    fn an_empty_key_is_no_key_here_too() {
729        // require_auth treats unset and empty alike; a caller composing
730        // api_key_configured into resolve_startup_auth must agree, or a server
731        // boots believing it is Enforced while every request sails through.
732        const ENV: &str = "TRONC_AUTH_TEST_POSTURE_EMPTY";
733        env::set_var(ENV, "");
734        assert!(!api_key_configured(ENV));
735        assert!(resolve_startup_auth("0.0.0.0", api_key_configured(ENV)).is_err());
736        env::remove_var(ENV);
737    }
738
739    #[test]
740    fn a_blank_key_is_no_key() {
741        // A whitespace-only value is not a secret: a reachable server holding
742        // only that must refuse to start, exactly as with no key at all.
743        const ENV: &str = "TRONC_AUTH_TEST_POSTURE_BLANK";
744        for blank in [" ", "\n", " \t\r\n"] {
745            env::set_var(ENV, blank);
746            assert!(!api_key_configured(ENV), "{blank:?} counted as a key");
747            assert!(
748                resolve_startup_auth("0.0.0.0", api_key_configured(ENV)).is_err(),
749                "{blank:?} let a reachable server start"
750            );
751        }
752        env::remove_var(ENV);
753    }
754}