1use 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#[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#[must_use]
39pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
40 bool::from(sha256(a).ct_eq(&sha256(b)))
41}
42
43pub 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 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}