ifc_lite_server/middleware/auth.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Optional bearer-token authentication for the compute/parse routes.
6//!
7//! Authentication is **off by default** so the public viewer -> server flow
8//! keeps working. When `Config::api_token` is set (via `IFC_SERVER_API_TOKEN`
9//! or `API_TOKEN`), the protected routes require an
10//! `Authorization: Bearer <token>` header and return `401 Unauthorized`
11//! otherwise. The health endpoint is intentionally left unprotected so this
12//! layer never applies to it.
13
14use std::sync::Arc;
15
16use axum::{
17 extract::State,
18 http::{header, HeaderValue, Request, StatusCode},
19 middleware::Next,
20 response::Response,
21};
22
23use crate::config::Config;
24use crate::error::error_response;
25
26/// Axum middleware enforcing optional bearer-token auth on protected routes.
27///
28/// If no token is configured the request passes through unchanged. If a token
29/// is configured, the `Authorization: Bearer <token>` header must match exactly
30/// (constant-time compare) or the request is rejected with `401`, in the same
31/// `{"error", "code"}` envelope as every other failure (#5750).
32pub async fn require_bearer_token(
33 State(config): State<Arc<Config>>,
34 request: Request<axum::body::Body>,
35 next: Next,
36) -> Result<Response, Response> {
37 // Auth disabled: pass through. (Startup warning is logged once in `main`.)
38 let Some(expected) = config.api_token.as_deref() else {
39 return Ok(next.run(request).await);
40 };
41
42 let presented = request
43 .headers()
44 .get(header::AUTHORIZATION)
45 .and_then(|value| value.to_str().ok())
46 .and_then(|value| value.strip_prefix("Bearer "))
47 .map(str::trim);
48
49 match presented {
50 Some(token) if constant_time_eq(token.as_bytes(), expected.as_bytes()) => {
51 Ok(next.run(request).await)
52 }
53 _ => {
54 tracing::warn!("Rejected request to protected route: missing or invalid bearer token");
55 Err(unauthorized())
56 }
57 }
58}
59
60/// The `401`: the shared error envelope plus the `WWW-Authenticate` challenge
61/// naming the scheme this layer accepts.
62fn unauthorized() -> Response {
63 let mut response = error_response(
64 StatusCode::UNAUTHORIZED,
65 "UNAUTHORIZED",
66 "Unauthorized: missing or invalid bearer token",
67 );
68 response
69 .headers_mut()
70 .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
71 response
72}
73
74/// Length-aware constant-time byte comparison to avoid leaking the token via
75/// early-exit timing. Returns `false` immediately on length mismatch (the
76/// length itself is not secret), then folds all remaining bytes.
77fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
78 if a.len() != b.len() {
79 return false;
80 }
81 let mut diff = 0u8;
82 for (x, y) in a.iter().zip(b.iter()) {
83 diff |= x ^ y;
84 }
85 diff == 0
86}
87
88// Tests live in a sibling file so this module stays a production-sized unit;
89// as a child module they keep `use super::*` access to `constant_time_eq`.
90#[cfg(test)]
91#[path = "auth_tests.rs"]
92mod auth_tests;