1use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use axum::{
14 extract::{FromRef, FromRequestParts},
15 http::{request::Parts, StatusCode},
16 response::{IntoResponse, Response},
17 Json,
18};
19use serde_json::json;
20
21use crate::state::BosonState;
22
23pub const REQUIRE_ADMIN_AUTH_ENV: &str = "BOSON_REQUIRE_ADMIN_AUTH";
25
26#[derive(Debug, Clone)]
28pub struct AdminAuthError {
29 message: String,
30}
31
32impl AdminAuthError {
33 #[must_use]
35 pub fn new(message: impl Into<String>) -> Self {
36 Self {
37 message: message.into(),
38 }
39 }
40
41 #[must_use]
43 pub fn message(&self) -> &str {
44 &self.message
45 }
46}
47
48pub trait AdminAuth: Send + Sync {
50 fn authorize<'a>(
56 &'a self,
57 parts: &'a Parts,
58 ) -> Pin<Box<dyn Future<Output = Result<(), AdminAuthError>> + Send + 'a>>;
59}
60
61#[must_use]
63pub fn parse_require_admin_auth(value: &str) -> bool {
64 let v = value.trim().to_ascii_lowercase();
65 matches!(v.as_str(), "1" | "true" | "yes")
66}
67
68#[must_use]
70pub fn require_admin_auth_from_env() -> bool {
71 std::env::var(REQUIRE_ADMIN_AUTH_ENV).is_ok_and(|v| parse_require_admin_auth(&v))
72}
73
74fn unauthorized(message: impl Into<String>) -> Response {
75 (
76 StatusCode::UNAUTHORIZED,
77 Json(json!({
78 "success": false,
79 "data": null,
80 "error": message.into(),
81 })),
82 )
83 .into_response()
84}
85
86pub struct RequireAdmin;
88
89impl<S> FromRequestParts<S> for RequireAdmin
90where
91 S: Send + Sync,
92 BosonState: FromRef<S>,
93{
94 type Rejection = Response;
95
96 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
97 let boson_state = BosonState::from_ref(state);
98 match (&boson_state.admin_auth, boson_state.require_admin_auth) {
99 (Some(auth), _) => match auth.authorize(parts).await {
100 Ok(()) => Ok(Self),
101 Err(e) => Err(unauthorized(e.message().to_string())),
102 },
103 (None, true) => Err(unauthorized(
104 "admin auth required but no AdminAuth verifier configured",
105 )),
106 (None, false) => Ok(Self),
107 }
108 }
109}
110
111#[derive(Debug, Default, Clone, Copy)]
113pub struct AllowAllAdminAuth;
114
115impl AdminAuth for AllowAllAdminAuth {
116 fn authorize<'a>(
117 &'a self,
118 _parts: &'a Parts,
119 ) -> Pin<Box<dyn Future<Output = Result<(), AdminAuthError>> + Send + 'a>> {
120 Box::pin(async { Ok(()) })
121 }
122}
123
124#[derive(Debug, Clone)]
128pub struct StaticTokenAdminAuth {
129 token: Arc<str>,
130}
131
132impl StaticTokenAdminAuth {
133 #[must_use]
135 pub fn new(token: impl Into<String>) -> Self {
136 Self {
137 token: Arc::from(token.into()),
138 }
139 }
140}
141
142impl AdminAuth for StaticTokenAdminAuth {
143 fn authorize<'a>(
144 &'a self,
145 parts: &'a Parts,
146 ) -> Pin<Box<dyn Future<Output = Result<(), AdminAuthError>> + Send + 'a>> {
147 let expected = Arc::clone(&self.token);
148 let header = parts
149 .headers
150 .get("x-boson-admin-token")
151 .and_then(|v| v.to_str().ok())
152 .map(str::to_owned);
153 Box::pin(async move {
154 match header {
155 Some(ref got) if got.as_str() == expected.as_ref() => Ok(()),
156 _ => Err(AdminAuthError::new(
157 "missing or invalid x-boson-admin-token",
158 )),
159 }
160 })
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use axum::http::Request;
167
168 use super::*;
169
170 fn parts_with_token(token: Option<&str>) -> Parts {
171 let mut builder = Request::builder().uri("/");
172 if let Some(t) = token {
173 builder = builder.header("x-boson-admin-token", t);
174 }
175 builder.body(()).expect("request").into_parts().0
176 }
177
178 #[test]
179 fn parse_require_admin_auth_truthy_and_falsy() {
180 for v in ["1", "true", "YES", " True "] {
181 assert!(parse_require_admin_auth(v), "expected truthy: {v}");
182 }
183 for v in ["0", "false", "no", ""] {
184 assert!(!parse_require_admin_auth(v), "expected falsy: {v}");
185 }
186 }
187
188 #[tokio::test]
189 async fn static_token_accepts_matching_header() {
190 let auth = StaticTokenAdminAuth::new("lab-secret");
191 let parts = parts_with_token(Some("lab-secret"));
192 assert!(auth.authorize(&parts).await.is_ok());
193 }
194
195 #[tokio::test]
196 async fn static_token_rejects_missing_or_wrong_header() {
197 let auth = StaticTokenAdminAuth::new("lab-secret");
198 assert!(auth.authorize(&parts_with_token(None)).await.is_err());
199 assert!(auth
200 .authorize(&parts_with_token(Some("nope")))
201 .await
202 .is_err());
203 }
204}