1use std::env;
21use std::error::Error;
22use std::fmt;
23use std::net::IpAddr;
24
25use axum::extract::Request;
26use axum::http::StatusCode;
27use axum::middleware::Next;
28use axum::response::{IntoResponse, Response};
29use axum::Json;
30use subtle::ConstantTimeEq;
31
32use crate::error::ErrorResponse;
33
34pub async fn require_auth(env_var: &str, request: Request, next: Next) -> Response {
53 let expected_key = match env::var(env_var) {
54 Ok(key) if !key.is_empty() => key,
55 _ => return next.run(request).await,
56 };
57
58 let auth_header = request
59 .headers()
60 .get("authorization")
61 .and_then(|v| v.to_str().ok());
62
63 match auth_header.map(bearer_credential) {
64 Some(Some(token)) => {
65 if token.as_bytes().ct_eq(expected_key.as_bytes()).into() {
66 next.run(request).await
67 } else {
68 auth_error("Invalid API key")
69 }
70 }
71 Some(None) => auth_error("Authorization header must use Bearer scheme"),
72 None => auth_error("Missing Authorization header"),
73 }
74}
75
76#[must_use]
86pub fn bearer_credential(header_value: &str) -> Option<&str> {
87 let (scheme, credential) = header_value.split_once(' ')?;
88 if !scheme.eq_ignore_ascii_case("Bearer") {
89 return None;
90 }
91 let credential = credential.trim_start_matches(' ');
92 (!credential.is_empty()).then_some(credential)
93}
94
95fn auth_error(message: &str) -> Response {
97 let body = ErrorResponse::new("authentication_error", message);
98 (StatusCode::UNAUTHORIZED, Json(body)).into_response()
99}
100
101#[must_use]
107pub fn api_key_configured(env_var: &str) -> bool {
108 matches!(env::var(env_var), Ok(key) if !key.is_empty())
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum AuthMode {
114 Enforced,
116 LoopbackDev,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct InsecureBindError {
137 pub host: String,
139}
140
141impl fmt::Display for InsecureBindError {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 write!(
144 f,
145 "refusing to start: nothing authenticates requests while binding non-loopback host \
146 '{}'. Arm this service's gate, or bind 127.0.0.1 for local development.",
147 self.host
148 )
149 }
150}
151
152impl Error for InsecureBindError {}
153
154#[must_use]
162pub fn is_loopback_host(host: &str) -> bool {
163 let trimmed = host.trim();
164 if trimmed.eq_ignore_ascii_case("localhost") {
165 return true;
166 }
167 let stripped = trimmed.strip_circumfix('[', ']').unwrap_or(trimmed);
168 stripped.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
169}
170
171pub fn resolve_startup_auth(host: &str, gated: bool) -> Result<AuthMode, InsecureBindError> {
204 if gated {
205 Ok(AuthMode::Enforced)
206 } else if is_loopback_host(host) {
207 Ok(AuthMode::LoopbackDev)
208 } else {
209 Err(InsecureBindError {
210 host: host.to_owned(),
211 })
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use axum::body::Body;
219 use axum::routing::get;
220 use axum::{middleware, Router};
221 use http::Request as HttpRequest;
222 use http_body_util::BodyExt;
223 use serde_json::Value;
224 use tower::ServiceExt;
225
226 async fn dummy_handler() -> &'static str {
228 "ok"
229 }
230
231 fn make_app(env_var: &'static str) -> Router {
232 Router::new()
233 .route("/test", get(dummy_handler))
234 .layer(middleware::from_fn(move |req, next| {
235 require_auth(env_var, req, next)
236 }))
237 }
238
239 #[tokio::test]
240 async fn no_env_allows_all_requests() {
241 const ENV: &str = "TRONC_AUTH_TEST_NO_ENV";
242 env::remove_var(ENV);
243 let app = make_app(ENV);
244 let req = HttpRequest::builder()
245 .uri("/test")
246 .body(Body::empty())
247 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 200);
251 }
252
253 #[tokio::test]
254 async fn empty_env_allows_all_requests() {
255 const ENV: &str = "TRONC_AUTH_TEST_EMPTY";
256 env::set_var(ENV, "");
257 let app = make_app(ENV);
258 let req = HttpRequest::builder()
259 .uri("/test")
260 .body(Body::empty())
261 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 200);
265 env::remove_var(ENV);
266 }
267
268 #[tokio::test]
269 async fn valid_bearer_token_passes() {
270 const ENV: &str = "TRONC_AUTH_TEST_VALID";
271 env::set_var(ENV, "secret-key-123");
272 let app = make_app(ENV);
273 let req = HttpRequest::builder()
274 .uri("/test")
275 .header("authorization", "Bearer secret-key-123")
276 .body(Body::empty())
277 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 200);
281 env::remove_var(ENV);
282 }
283
284 #[tokio::test]
285 async fn invalid_bearer_token_returns_401() {
286 const ENV: &str = "TRONC_AUTH_TEST_INVALID";
287 env::set_var(ENV, "secret-key-123");
288 let app = make_app(ENV);
289 let req = HttpRequest::builder()
290 .uri("/test")
291 .header("authorization", "Bearer wrong-key")
292 .body(Body::empty())
293 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
297
298 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");
301 env::remove_var(ENV);
302 }
303
304 #[tokio::test]
305 async fn missing_header_returns_401() {
306 const ENV: &str = "TRONC_AUTH_TEST_MISSING";
307 env::set_var(ENV, "secret-key-123");
308 let app = make_app(ENV);
309 let req = HttpRequest::builder()
310 .uri("/test")
311 .body(Body::empty())
312 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
316 env::remove_var(ENV);
317 }
318
319 #[tokio::test]
320 async fn non_bearer_scheme_returns_401() {
321 const ENV: &str = "TRONC_AUTH_TEST_SCHEME";
322 env::set_var(ENV, "secret-key-123");
323 let app = make_app(ENV);
324 let req = HttpRequest::builder()
325 .uri("/test")
326 .header("authorization", "Basic dXNlcjpwYXNz")
327 .body(Body::empty())
328 .expect("request"); let resp = app.oneshot(req).await.expect("response"); assert_eq!(resp.status(), 401);
332
333 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"]
336 .as_str()
337 .expect("msg") .contains("Bearer"));
339 env::remove_var(ENV);
340 }
341
342 #[tokio::test]
345 async fn lowercase_bearer_scheme_passes() {
346 const ENV: &str = "TRONC_AUTH_TEST_LOWERCASE_SCHEME";
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 #[test]
361 fn bearer_credential_reads_the_scheme_in_any_case() {
362 for header in [
363 "Bearer tok-1",
364 "bearer tok-1",
365 "BEARER tok-1",
366 "BeArEr tok-1",
367 "Bearer tok-1",
368 ] {
369 assert_eq!(
370 bearer_credential(header),
371 Some("tok-1"),
372 "{header:?} carries the credential tok-1"
373 );
374 }
375 }
376
377 #[test]
378 fn bearer_credential_refuses_other_schemes_and_empty_credentials() {
379 for header in [
380 "Basic dXNlcjpwYXNz",
381 "Bearertok-1",
382 "Bearer",
383 "Bearer ",
384 "Bearer ",
385 "tok-1",
386 "",
387 ] {
388 assert_eq!(
389 bearer_credential(header),
390 None,
391 "{header:?} must not read as a bearer credential"
392 );
393 }
394 }
395
396 #[test]
399 fn loopback_hosts_are_recognised() {
400 for host in [
401 "127.0.0.1",
402 "127.1.2.3",
403 "::1",
404 "[::1]",
405 "localhost",
406 "LocalHost",
407 ] {
408 assert!(is_loopback_host(host), "{host} should be loopback");
409 }
410 }
411
412 #[test]
413 fn anything_reachable_or_unparseable_is_not_loopback() {
414 for host in ["0.0.0.0", "::", "192.168.1.10", "example.com", "", " "] {
418 assert!(
419 !is_loopback_host(host),
420 "{host:?} must not read as loopback"
421 );
422 }
423 }
424
425 #[test]
426 fn a_reachable_bind_with_nothing_gating_it_is_refused() {
427 let err = resolve_startup_auth("0.0.0.0", false)
428 .expect_err("a reachable bind with no gate must be refused"); assert_eq!(err.host, "0.0.0.0");
431 let msg = err.to_string();
432 assert!(msg.contains("refusing to start"), "message was {msg:?}");
433 assert!(
434 msg.contains("0.0.0.0"),
435 "the message must name the bind that was refused; message was {msg:?}"
436 );
437 }
438
439 #[test]
440 fn an_identity_gated_service_binds_a_reachable_host_holding_no_key() {
441 const ENV: &str = "TRONC_AUTH_TEST_POSTURE_NO_KEY_HERE";
449 env::remove_var(ENV);
450 assert!(!api_key_configured(ENV));
451
452 assert_eq!(
453 resolve_startup_auth("0.0.0.0", true),
454 Ok(AuthMode::Enforced)
455 );
456 }
457
458 #[test]
459 fn loopback_without_a_gate_is_development_not_a_refusal() {
460 assert_eq!(
461 resolve_startup_auth("127.0.0.1", false),
462 Ok(AuthMode::LoopbackDev)
463 );
464 }
465
466 #[test]
467 fn a_gate_enforces_on_every_host() {
468 assert_eq!(
469 resolve_startup_auth("0.0.0.0", true),
470 Ok(AuthMode::Enforced)
471 );
472 assert_eq!(
473 resolve_startup_auth("127.0.0.1", true),
474 Ok(AuthMode::Enforced)
475 );
476 }
477
478 #[test]
479 fn an_empty_key_is_no_key_here_too() {
480 const ENV: &str = "TRONC_AUTH_TEST_POSTURE_EMPTY";
484 env::set_var(ENV, "");
485 assert!(!api_key_configured(ENV));
486 assert!(resolve_startup_auth("0.0.0.0", api_key_configured(ENV)).is_err());
487 env::remove_var(ENV);
488 }
489}