Skip to main content

authx_dashboard/
lib.rs

1//! authx-dashboard — embedded admin dashboard (HTMX-powered)
2//!
3//! # Mounting
4//! ```ignore
5//! use authx_dashboard::DashboardState;
6//!
7//! let dashboard = DashboardState::new(store.clone(), events.clone(), 86400);
8//! let app = Router::new()
9//!     .nest("/_authx", dashboard.router("my-secret-admin-token"));
10//! ```
11//!
12//! All API routes require `Authorization: Bearer <admin_token>`.
13//! The UI page itself is served without authentication so the login form can
14//! be presented; the embedded JS stores the token in sessionStorage.
15
16mod api;
17mod html;
18
19use std::sync::Arc;
20
21use authx_core::events::EventBus;
22use authx_plugins::AdminService;
23use authx_storage::ports::{
24    AuditLogRepository, DeviceCodeRepository, OidcClientRepository,
25    OidcFederationProviderRepository, OrgRepository, SessionRepository, UserRepository,
26};
27use axum::{
28    Router,
29    extract::State,
30    http::{Request, StatusCode},
31    middleware::{self, Next},
32    response::Response,
33};
34use subtle::ConstantTimeEq;
35
36/// Shared state threaded through every dashboard route.
37#[derive(Clone)]
38pub struct DashboardState<S> {
39    pub(crate) admin: Arc<AdminService<S>>,
40    pub(crate) token: Arc<String>,
41}
42
43impl<S> DashboardState<S>
44where
45    S: UserRepository
46        + SessionRepository
47        + OrgRepository
48        + AuditLogRepository
49        + OidcClientRepository
50        + OidcFederationProviderRepository
51        + DeviceCodeRepository
52        + Clone
53        + Send
54        + Sync
55        + 'static,
56{
57    pub fn new(storage: S, events: EventBus, session_ttl_secs: i64) -> Self {
58        Self {
59            admin: Arc::new(AdminService::new(storage, events, session_ttl_secs)),
60            token: Arc::new(String::new()),
61        }
62    }
63
64    /// Build the dashboard [`Router`].
65    pub fn router(mut self, admin_token: impl Into<String>) -> Router {
66        self.token = Arc::new(admin_token.into());
67
68        let state = self.clone();
69
70        Router::new()
71            .merge(html::routes())
72            .nest("/api", api::routes::<S>().with_state(self))
73            .layer(middleware::from_fn_with_state(state, bearer_auth::<S>))
74    }
75}
76
77async fn bearer_auth<S>(
78    State(state): State<DashboardState<S>>,
79    req: Request<axum::body::Body>,
80    next: Next,
81) -> Result<Response, StatusCode>
82where
83    S: Clone + Send + Sync + 'static,
84{
85    // The root HTML page is always served — JS handles the auth token prompt.
86    if req.uri().path() == "/" {
87        return Ok(next.run(req).await);
88    }
89
90    let provided = req
91        .headers()
92        .get("authorization")
93        .and_then(|v| v.to_str().ok())
94        .and_then(|s| s.strip_prefix("Bearer "))
95        .unwrap_or("");
96
97    let token_bytes = state.token.as_bytes();
98    let valid = !provided.is_empty()
99        && provided.len() == token_bytes.len()
100        && provided.as_bytes().ct_eq(token_bytes).unwrap_u8() == 1;
101
102    if !valid {
103        tracing::warn!("dashboard: rejected request — invalid or missing admin token");
104        return Err(StatusCode::UNAUTHORIZED);
105    }
106
107    Ok(next.run(req).await)
108}