1use 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
37pub 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#[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
97fn auth_error(message: &str) -> Response {
99 let body = ErrorResponse::new("authentication_error", message);
100 (StatusCode::UNAUTHORIZED, Json(body)).into_response()
101}
102
103fn configured_key(env_var: &str) -> Option<String> {
111 env::var(env_var).ok().filter(|key| !key.trim().is_empty())
112}
113
114#[must_use]
120pub fn api_key_configured(env_var: &str) -> bool {
121 configured_key(env_var).is_some()
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum AuthMode {
127 Enforced,
129 LoopbackDev,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct InsecureBindError {
150 pub host: String,
152 pub gate: Option<String>,
157}
158
159impl InsecureBindError {
160 #[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#[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
221pub 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
263pub 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 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"); let resp = app.oneshot(req).await.expect("response"); 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"); let resp = app.oneshot(req).await.expect("response"); 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 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"); let resp = app.oneshot(req).await.expect("response"); 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"); let resp = app.oneshot(req).await.expect("response"); 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"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
401
402 let bytes = resp.into_body().collect().await.expect("body").to_bytes(); let json: Value = serde_json::from_slice(&bytes).expect("json"); 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"); let resp = app.oneshot(req).await.expect("response"); 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"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
436
437 let bytes = resp.into_body().collect().await.expect("body").to_bytes(); let json: Value = serde_json::from_slice(&bytes).expect("json"); assert!(json["error"]["message"]
440 .as_str()
441 .expect("msg") .contains("Bearer"));
443 env::remove_var(ENV);
444 }
445
446 #[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"); let resp = app.oneshot(req).await.expect("response"); 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 #[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 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"); 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 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 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") }
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"); 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") .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 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 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}