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