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
103pub(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#[must_use]
126pub fn api_key_configured(env_var: &str) -> bool {
127 configured_key(env_var).is_some()
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum AuthMode {
133 Enforced,
135 LoopbackDev,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct InsecureBindError {
156 pub host: String,
158 pub gate: Option<String>,
163}
164
165impl InsecureBindError {
166 #[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#[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
227pub 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
269pub 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 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"); let resp = app.oneshot(req).await.expect("response"); 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"); let resp = app.oneshot(req).await.expect("response"); 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 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"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 200);
374 assert!(!api_key_configured(ENV));
375 env::remove_var(ENV);
376 }
377
378 #[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"); let resp = make_app(ENV).oneshot(req).await.expect("response"); 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"); let resp = app.oneshot(req).await.expect("response"); 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"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
432
433 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");
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"); let resp = app.oneshot(req).await.expect("response"); 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"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
467
468 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"]
471 .as_str()
472 .expect("msg") .contains("Bearer"));
474 env::remove_var(ENV);
475 }
476
477 #[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"); let resp = app.oneshot(req).await.expect("response"); 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 #[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 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"); 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 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 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") }
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"); 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") .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 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 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}