Skip to main content

cratefield_core/
admin.rs

1//! Admin endpoint authentication (architecture section 11, issue #13 —
2//! landed with issue #10 because the first admin endpoint needs it).
3//!
4//! `ADMIN_TOKEN` is read from the [`Config`] (a Workers secret in
5//! production). While it is unset, every admin route is **disabled** and
6//! answers `401 admin-unauthorized`. A presented-but-wrong token is `403`.
7//! Comparison digests both sides with SHA-256 first so the `subtle`
8//! constant-time compare runs over fixed-length buffers regardless of the
9//! presented length, and the token never appears in tracing fields.
10
11use axum::http::{HeaderMap, header};
12use sha2::{Digest, Sha256};
13use subtle::ConstantTimeEq;
14
15use crate::config::Config;
16use crate::problem::Problem;
17use crate::problems::SLUGS;
18
19/// Extracts the bearer token from `Authorization: Bearer <token>`.
20#[must_use]
21pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
22    let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
23    let rest = value
24        .strip_prefix("Bearer ")
25        .or_else(|| value.strip_prefix("bearer "))?;
26    if rest.is_empty() { None } else { Some(rest) }
27}
28
29fn sha256(bytes: &[u8]) -> [u8; 32] {
30    let digest = Sha256::digest(bytes);
31    let mut out = [0u8; 32];
32    out.copy_from_slice(&digest);
33    out
34}
35
36/// Constant-time equality of two secrets, via fixed-length digests so
37/// timing does not leak the configured token's length.
38#[must_use]
39pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
40    bool::from(sha256(a).ct_eq(&sha256(b)))
41}
42
43/// Checks `Authorization: Bearer <ADMIN_TOKEN>` for one admin request.
44///
45/// - `ADMIN_TOKEN` unset → `Err(401 admin-unauthorized)`: the route is
46///   disabled, same answer as a missing header, so probing cannot tell
47///   whether admin is switched on.
48/// - Missing/malformed header → `Err(401 admin-unauthorized)`.
49/// - Wrong token → `Err(403 admin-forbidden)`.
50///
51/// # Errors
52///
53/// The [`Problem`] described above; carry it straight into the handler's
54/// `Err`.
55pub fn require_admin(config: &dyn Config, headers: &HeaderMap) -> Result<(), Problem> {
56    let Some(configured) = config.get("ADMIN_TOKEN").filter(|token| !token.is_empty()) else {
57        return Err(Problem::new(&SLUGS.admin_unauthorized));
58    };
59    let Some(presented) = bearer_token(headers) else {
60        return Err(Problem::new(&SLUGS.admin_unauthorized));
61    };
62    if constant_time_eq(presented.as_bytes(), configured.as_bytes()) {
63        Ok(())
64    } else {
65        Err(Problem::new(&SLUGS.admin_forbidden))
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use crate::MapConfig;
73
74    fn headers(token: Option<&str>) -> HeaderMap {
75        let mut map = HeaderMap::new();
76        if let Some(token) = token {
77            map.insert(
78                header::AUTHORIZATION,
79                header::HeaderValue::from_str(&format!("Bearer {token}")).expect("header builds"),
80            );
81        }
82        map
83    }
84
85    fn config(token: Option<&str>) -> MapConfig {
86        MapConfig::from_pairs(token.map(|t| ("ADMIN_TOKEN", t)))
87    }
88
89    // A dummy, never-real token: obviously fake, long enough to be realistic.
90    const DUMMY: &str = "test-admin-token-0123456789abcdef";
91
92    #[test]
93    fn unset_token_disables_admin() {
94        let err = require_admin(&config(None), &headers(Some(DUMMY))).expect_err("disabled");
95        assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED);
96        assert_eq!(err.slug, "admin-unauthorized");
97    }
98
99    #[test]
100    fn missing_header_is_unauthorized() {
101        let err = require_admin(&config(Some(DUMMY)), &headers(None)).expect_err("no header");
102        assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED);
103    }
104
105    #[test]
106    fn wrong_token_is_forbidden() {
107        let err =
108            require_admin(&config(Some(DUMMY)), &headers(Some("wrong"))).expect_err("wrong token");
109        assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN);
110        assert_eq!(err.slug, "admin-forbidden");
111    }
112
113    #[test]
114    fn correct_token_passes() {
115        require_admin(&config(Some(DUMMY)), &headers(Some(DUMMY))).expect("authorized");
116    }
117
118    #[test]
119    fn empty_bearer_is_unauthorized() {
120        let mut map = HeaderMap::new();
121        map.insert(
122            header::AUTHORIZATION,
123            header::HeaderValue::from_static("Bearer "),
124        );
125        let err = require_admin(&config(Some(DUMMY)), &map).expect_err("empty bearer");
126        assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED);
127    }
128
129    #[test]
130    fn constant_time_eq_matches_and_rejects() {
131        assert!(constant_time_eq(b"abcdef", b"abcdef"));
132        assert!(!constant_time_eq(b"abcdef", b"abcdef-longer"));
133        assert!(!constant_time_eq(b"abcdef", b"abcdeg"));
134    }
135}