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 expected_key = match env::var(env_var) {
57 Ok(key) if !key.is_empty() => key,
58 _ => return next.run(request).await,
59 };
60
61 let auth_header = request
62 .headers()
63 .get("authorization")
64 .and_then(|v| v.to_str().ok());
65
66 match auth_header.map(bearer_credential) {
67 Some(Some(token)) => {
68 if token.as_bytes().ct_eq(expected_key.as_bytes()).into() {
69 next.run(request).await
70 } else {
71 auth_error("Invalid API key")
72 }
73 }
74 Some(None) => auth_error("Authorization header must use Bearer scheme"),
75 None => auth_error("Missing Authorization header"),
76 }
77}
78
79#[must_use]
89pub fn bearer_credential(header_value: &str) -> Option<&str> {
90 let (scheme, credential) = header_value.split_once(' ')?;
91 if !scheme.eq_ignore_ascii_case("Bearer") {
92 return None;
93 }
94 let credential = credential.trim_start_matches(' ');
95 (!credential.is_empty()).then_some(credential)
96}
97
98fn auth_error(message: &str) -> Response {
100 let body = ErrorResponse::new("authentication_error", message);
101 (StatusCode::UNAUTHORIZED, Json(body)).into_response()
102}
103
104#[must_use]
110pub fn api_key_configured(env_var: &str) -> bool {
111 matches!(env::var(env_var), Ok(key) if !key.is_empty())
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum AuthMode {
117 Enforced,
119 LoopbackDev,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct InsecureBindError {
140 pub host: String,
142 pub gate: Option<String>,
147}
148
149impl InsecureBindError {
150 #[must_use]
152 pub fn new(host: impl Into<String>) -> Self {
153 Self {
154 host: host.into(),
155 gate: None,
156 }
157 }
158}
159
160impl fmt::Display for InsecureBindError {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 write!(
163 f,
164 "refusing to start: nothing authenticates requests while binding non-loopback host \
165 '{}'. ",
166 self.host
167 )?;
168 match &self.gate {
169 Some(var) => write!(
170 f,
171 "Set {var} (every request must then carry it as a bearer token), or bind \
172 127.0.0.1 for local development."
173 ),
174 None => {
175 f.write_str("Arm this service's gate, or bind 127.0.0.1 for local development.")
176 }
177 }
178 }
179}
180
181impl Error for InsecureBindError {}
182
183#[must_use]
202pub fn is_loopback_host(host: &str) -> bool {
203 let trimmed = host.trim();
204 if trimmed.eq_ignore_ascii_case("localhost") {
205 return true;
206 }
207 let stripped = trimmed.strip_circumfix('[', ']').unwrap_or(trimmed);
208 stripped.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
209}
210
211pub fn resolve_startup_auth(host: &str, gated: bool) -> Result<AuthMode, InsecureBindError> {
244 if gated {
245 Ok(AuthMode::Enforced)
246 } else if is_loopback_host(host) {
247 Ok(AuthMode::LoopbackDev)
248 } else {
249 Err(InsecureBindError::new(host))
250 }
251}
252
253pub fn startup_auth(env_var: &str, host: &str) -> Result<AuthMode, InsecureBindError> {
272 let mode = resolve_startup_auth(host, api_key_configured(env_var)).map_err(|refused| {
273 InsecureBindError {
274 gate: Some(env_var.to_owned()),
275 ..refused
276 }
277 })?;
278 match mode {
279 AuthMode::Enforced => info!(
280 gate = env_var,
281 "{env_var} set — every request must carry it as a bearer token"
282 ),
283 AuthMode::LoopbackDev => warn!(
284 gate = env_var,
285 host, "{env_var} unset — serving UNAUTHENTICATED on loopback for local development"
286 ),
287 }
288 Ok(mode)
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use axum::body::Body;
295 use axum::routing::get;
296 use axum::{middleware, Router};
297 use http::Request as HttpRequest;
298 use http_body_util::BodyExt;
299 use serde_json::Value;
300 use tower::ServiceExt;
301
302 async fn dummy_handler() -> &'static str {
304 "ok"
305 }
306
307 fn make_app(env_var: &'static str) -> Router {
308 Router::new()
309 .route("/test", get(dummy_handler))
310 .layer(middleware::from_fn(move |req, next| {
311 require_auth(env_var, req, next)
312 }))
313 }
314
315 #[tokio::test]
316 async fn no_env_allows_all_requests() {
317 const ENV: &str = "TRONC_AUTH_TEST_NO_ENV";
318 env::remove_var(ENV);
319 let app = make_app(ENV);
320 let req = HttpRequest::builder()
321 .uri("/test")
322 .body(Body::empty())
323 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 200);
327 }
328
329 #[tokio::test]
330 async fn empty_env_allows_all_requests() {
331 const ENV: &str = "TRONC_AUTH_TEST_EMPTY";
332 env::set_var(ENV, "");
333 let app = make_app(ENV);
334 let req = HttpRequest::builder()
335 .uri("/test")
336 .body(Body::empty())
337 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 200);
341 env::remove_var(ENV);
342 }
343
344 #[tokio::test]
345 async fn valid_bearer_token_passes() {
346 const ENV: &str = "TRONC_AUTH_TEST_VALID";
347 env::set_var(ENV, "secret-key-123");
348 let app = make_app(ENV);
349 let req = HttpRequest::builder()
350 .uri("/test")
351 .header("authorization", "Bearer secret-key-123")
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 invalid_bearer_token_returns_401() {
362 const ENV: &str = "TRONC_AUTH_TEST_INVALID";
363 env::set_var(ENV, "secret-key-123");
364 let app = make_app(ENV);
365 let req = HttpRequest::builder()
366 .uri("/test")
367 .header("authorization", "Bearer wrong-key")
368 .body(Body::empty())
369 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
373
374 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");
377 env::remove_var(ENV);
378 }
379
380 #[tokio::test]
381 async fn missing_header_returns_401() {
382 const ENV: &str = "TRONC_AUTH_TEST_MISSING";
383 env::set_var(ENV, "secret-key-123");
384 let app = make_app(ENV);
385 let req = HttpRequest::builder()
386 .uri("/test")
387 .body(Body::empty())
388 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
392 env::remove_var(ENV);
393 }
394
395 #[tokio::test]
396 async fn non_bearer_scheme_returns_401() {
397 const ENV: &str = "TRONC_AUTH_TEST_SCHEME";
398 env::set_var(ENV, "secret-key-123");
399 let app = make_app(ENV);
400 let req = HttpRequest::builder()
401 .uri("/test")
402 .header("authorization", "Basic dXNlcjpwYXNz")
403 .body(Body::empty())
404 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
408
409 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"]
412 .as_str()
413 .expect("msg") .contains("Bearer"));
415 env::remove_var(ENV);
416 }
417
418 #[tokio::test]
421 async fn lowercase_bearer_scheme_passes() {
422 const ENV: &str = "TRONC_AUTH_TEST_LOWERCASE_SCHEME";
423 env::set_var(ENV, "secret-key-123");
424 let app = make_app(ENV);
425 let req = HttpRequest::builder()
426 .uri("/test")
427 .header("authorization", "bearer secret-key-123")
428 .body(Body::empty())
429 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 200);
433 env::remove_var(ENV);
434 }
435
436 #[test]
437 fn bearer_credential_reads_the_scheme_in_any_case() {
438 for header in [
439 "Bearer tok-1",
440 "bearer tok-1",
441 "BEARER tok-1",
442 "BeArEr tok-1",
443 "Bearer tok-1",
444 ] {
445 assert_eq!(
446 bearer_credential(header),
447 Some("tok-1"),
448 "{header:?} carries the credential tok-1"
449 );
450 }
451 }
452
453 #[test]
454 fn bearer_credential_refuses_other_schemes_and_empty_credentials() {
455 for header in [
456 "Basic dXNlcjpwYXNz",
457 "Bearertok-1",
458 "Bearer",
459 "Bearer ",
460 "Bearer ",
461 "tok-1",
462 "",
463 ] {
464 assert_eq!(
465 bearer_credential(header),
466 None,
467 "{header:?} must not read as a bearer credential"
468 );
469 }
470 }
471
472 #[test]
475 fn loopback_hosts_are_recognised() {
476 for host in [
477 "127.0.0.1",
478 "127.1.2.3",
479 "::1",
480 "[::1]",
481 "localhost",
482 "LocalHost",
483 ] {
484 assert!(is_loopback_host(host), "{host} should be loopback");
485 }
486 }
487
488 #[test]
489 fn anything_reachable_or_unparseable_is_not_loopback() {
490 for host in ["0.0.0.0", "::", "192.168.1.10", "example.com", "", " "] {
494 assert!(
495 !is_loopback_host(host),
496 "{host:?} must not read as loopback"
497 );
498 }
499 }
500
501 #[test]
502 fn a_reachable_bind_with_nothing_gating_it_is_refused() {
503 let err = resolve_startup_auth("0.0.0.0", false)
504 .expect_err("a reachable bind with no gate must be refused"); assert_eq!(err.host, "0.0.0.0");
507 let msg = err.to_string();
508 assert!(msg.contains("refusing to start"), "message was {msg:?}");
509 assert!(
510 msg.contains("0.0.0.0"),
511 "the message must name the bind that was refused; message was {msg:?}"
512 );
513 }
514
515 #[test]
516 fn an_identity_gated_service_binds_a_reachable_host_holding_no_key() {
517 const ENV: &str = "TRONC_AUTH_TEST_POSTURE_NO_KEY_HERE";
525 env::remove_var(ENV);
526 assert!(!api_key_configured(ENV));
527
528 assert_eq!(
529 resolve_startup_auth("0.0.0.0", true),
530 Ok(AuthMode::Enforced)
531 );
532 }
533
534 #[test]
535 fn loopback_without_a_gate_is_development_not_a_refusal() {
536 assert_eq!(
537 resolve_startup_auth("127.0.0.1", false),
538 Ok(AuthMode::LoopbackDev)
539 );
540 }
541
542 #[test]
543 fn a_gate_enforces_on_every_host() {
544 assert_eq!(
545 resolve_startup_auth("0.0.0.0", true),
546 Ok(AuthMode::Enforced)
547 );
548 assert_eq!(
549 resolve_startup_auth("127.0.0.1", true),
550 Ok(AuthMode::Enforced)
551 );
552 }
553
554 fn captured_logs(f: impl FnOnce()) -> String {
558 use std::io;
559 use std::sync::{Arc, Mutex, PoisonError};
560 use tracing::subscriber::with_default;
561 use tracing_subscriber::fmt::MakeWriter;
562
563 #[derive(Clone, Default)]
564 struct Buffer(Arc<Mutex<Vec<u8>>>);
565
566 impl io::Write for Buffer {
567 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
568 self.0
569 .lock()
570 .unwrap_or_else(PoisonError::into_inner)
571 .extend_from_slice(bytes);
572 Ok(bytes.len())
573 }
574
575 fn flush(&mut self) -> io::Result<()> {
576 Ok(())
577 }
578 }
579
580 impl<'a> MakeWriter<'a> for Buffer {
581 type Writer = Self;
582
583 fn make_writer(&'a self) -> Self::Writer {
584 self.clone()
585 }
586 }
587
588 let buffer = Buffer::default();
589 let subscriber = tracing_subscriber::fmt()
590 .with_writer(buffer.clone())
591 .with_ansi(false)
592 .finish();
593 with_default(subscriber, f);
594 let bytes = buffer
595 .0
596 .lock()
597 .unwrap_or_else(PoisonError::into_inner)
598 .clone();
599 String::from_utf8(bytes).expect("utf-8 log") }
601
602 #[test]
603 fn startup_auth_refuses_a_reachable_bind_and_names_the_key_to_set() {
604 const ENV: &str = "TRONC_STARTUP_AUTH_TEST_REFUSED";
605 env::remove_var(ENV);
606
607 let err =
608 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");
611 assert_eq!(err.gate.as_deref(), Some(ENV));
612 let msg = err.to_string();
613 assert!(
614 msg.contains(&format!("Set {ENV}")),
615 "the refusal must name the variable that arms the gate; message was {msg:?}"
616 );
617 }
618
619 #[test]
620 fn startup_auth_enforces_when_the_key_is_set_and_says_so() {
621 const ENV: &str = "TRONC_STARTUP_AUTH_TEST_ENFORCED";
622 env::set_var(ENV, "k-123");
623
624 let mut mode = None;
625 let logs = captured_logs(|| mode = Some(startup_auth(ENV, "0.0.0.0")));
626
627 assert_eq!(mode, Some(Ok(AuthMode::Enforced)));
628 assert!(logs.contains(" INFO "), "logs were {logs:?}");
629 assert!(
630 logs.contains(&format!("{ENV} set")),
631 "the INFO line must name the key; logs were {logs:?}"
632 );
633 assert!(
634 !logs.contains("k-123"),
635 "the key's value must never be logged"
636 );
637 env::remove_var(ENV);
638 }
639
640 #[test]
641 fn startup_auth_warns_when_loopback_serves_unauthenticated() {
642 const ENV: &str = "TRONC_STARTUP_AUTH_TEST_LOOPBACK";
643 env::set_var(ENV, "");
644
645 let mut mode = None;
646 let logs = captured_logs(|| mode = Some(startup_auth(ENV, "localhost")));
647
648 assert_eq!(mode, Some(Ok(AuthMode::LoopbackDev)));
649 assert!(logs.contains(" WARN "), "logs were {logs:?}");
650 assert!(
651 logs.contains(&format!("{ENV} unset")) && logs.contains("UNAUTHENTICATED"),
652 "the WARN line must say which key is missing; logs were {logs:?}"
653 );
654 env::remove_var(ENV);
655 }
656
657 #[test]
658 fn a_refusal_with_no_single_gate_asks_for_the_gate_generically() {
659 let msg = resolve_startup_auth("0.0.0.0", false)
660 .expect_err("refused") .to_string();
662 assert!(
663 msg.contains("Arm this service's gate"),
664 "message was {msg:?}"
665 );
666 }
667
668 #[test]
669 fn an_empty_key_is_no_key_here_too() {
670 const ENV: &str = "TRONC_AUTH_TEST_POSTURE_EMPTY";
674 env::set_var(ENV, "");
675 assert!(!api_key_configured(ENV));
676 assert!(resolve_startup_auth("0.0.0.0", api_key_configured(ENV)).is_err());
677 env::remove_var(ENV);
678 }
679}