Skip to main content

chronon_axum/
auth.rs

1//! Optional admin authentication for `/api/chronon`.
2//!
3//! Chronon does not ship Soliton/HMAC. Hosts implement [`AdminAuth`] and attach it via
4//! [`ChrononStateBuilder`](crate::ChrononStateBuilder). When [`require_admin_auth_from_env`] is
5//! true and no verifier is configured, builder/`RequireAdmin` 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::ChrononState;
22
23/// Environment variable: when `1`/`true`/`yes`, admin routes require a configured [`AdminAuth`].
24pub const REQUIRE_ADMIN_AUTH_ENV: &str = "CHRONON_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 Chronon 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/// Read [`REQUIRE_ADMIN_AUTH_ENV`]: `1`, `true`, or `yes` (case-insensitive) ⇒ required.
62#[must_use]
63pub fn require_admin_auth_from_env() -> bool {
64    match std::env::var(REQUIRE_ADMIN_AUTH_ENV) {
65        Ok(v) => {
66            let v = v.trim().to_ascii_lowercase();
67            matches!(v.as_str(), "1" | "true" | "yes")
68        }
69        Err(_) => false,
70    }
71}
72
73fn unauthorized(message: impl Into<String>) -> Response {
74    (
75        StatusCode::UNAUTHORIZED,
76        Json(json!({
77            "success": false,
78            "data": null,
79            "error": message.into(),
80        })),
81    )
82        .into_response()
83}
84
85/// Extractor that enforces [`ChrononState::admin_auth`] / require-flag before the handler runs.
86#[derive(Debug)]
87pub struct RequireAdmin;
88
89impl<S> FromRequestParts<S> for RequireAdmin
90where
91    S: Send + Sync,
92    ChrononState: 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 chronon_state = ChrononState::from_ref(state);
98        match (&chronon_state.admin_auth, chronon_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-chronon-admin-token` equal to the configured secret.
125///
126/// Intended for tests and simple lab setups. Prefer host mTLS/HMAC in production (Higgs).
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-chronon-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-chronon-admin-token",
158                )),
159            }
160        })
161    }
162}