Skip to main content

boson_axum/
auth.rs

1//! Optional admin authentication for `/api/boson`.
2//!
3//! Boson does not ship Soliton/HMAC. Hosts implement [`AdminAuth`] and attach it via
4//! [`BosonStateBuilder`](crate::BosonStateBuilder). When [`require_admin_auth_from_env`] is true
5//! and no verifier is configured, requests are rejected (fail closed).
6//!
7//! Handlers take [`RequireAdmin`] as an extractor so auth runs with the host's `FromRef` state.
8
9use 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
23/// Environment variable: when `1`/`true`/`yes`, admin routes require a configured [`AdminAuth`].
24pub const REQUIRE_ADMIN_AUTH_ENV: &str = "BOSON_REQUIRE_ADMIN_AUTH";
25
26/// Rejection from [`AdminAuth::authorize`].
27#[derive(Debug, Clone)]
28pub struct AdminAuthError {
29    message: String,
30}
31
32impl AdminAuthError {
33    /// Create an authorization error with a safe (non-secret) message.
34    #[must_use]
35    pub fn new(message: impl Into<String>) -> Self {
36        Self {
37            message: message.into(),
38        }
39    }
40
41    /// Operator-safe message.
42    #[must_use]
43    pub fn message(&self) -> &str {
44        &self.message
45    }
46}
47
48/// Host-supplied verifier for Boson admin HTTP.
49pub trait AdminAuth: Send + Sync {
50    /// Authorize one request from its HTTP parts (headers, URI, method).
51    ///
52    /// # Errors
53    ///
54    /// Return [`AdminAuthError`] when the caller must not access admin routes.
55    fn authorize<'a>(
56        &'a self,
57        parts: &'a Parts,
58    ) -> Pin<Box<dyn Future<Output = Result<(), AdminAuthError>> + Send + 'a>>;
59}
60
61/// Parse a require-admin-auth flag string (`1` / `true` / `yes`, case-insensitive).
62#[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/// Read [`REQUIRE_ADMIN_AUTH_ENV`]: `1`, `true`, or `yes` (case-insensitive) ⇒ required.
69#[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
86/// Extractor that enforces [`BosonState::admin_auth`] / require-flag before the handler runs.
87pub 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/// Shared always-allow verifier for local tests (not for production).
112#[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/// Header-based verifier: require `x-boson-admin-token` equal to the configured secret.
125///
126/// Intended for tests and simple lab setups. Prefer host mTLS/HMAC in production.
127#[derive(Debug, Clone)]
128pub struct StaticTokenAdminAuth {
129    token: Arc<str>,
130}
131
132impl StaticTokenAdminAuth {
133    /// Create a verifier that accepts a single shared token.
134    #[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}