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;