Skip to main content

acme_proxy/webadmin/pages/
auth.rs

1//! The page layer's session extractors.
2//!
3//! Thin wrappers over [`Authenticated`] and [`AuthenticatedWrite`], and
4//! deliberately nothing more. `resolve_session`, `check_csrf` and
5//! `check_origin` stay the single implementation in
6//! [`crate::webadmin::session`]: a second copy written "for pages" is how the
7//! two front ends would drift into disagreeing about what a live session is.
8//!
9//! All these add is the answer to a failure. The API says `401`; a browser
10//! needs to *arrive* at the sign-in page, and htmx needs to be told to navigate
11//! rather than swap — see [`super::error::PageError`].
12
13use axum::extract::FromRequestParts;
14use axum::http::HeaderMap;
15use axum::http::request::Parts;
16
17use crate::webadmin::AdminState;
18use crate::webadmin::error::AdminError;
19use crate::webadmin::pages::error::PageError;
20use crate::webadmin::session::{
21    Authenticated, AuthenticatedWrite, EnrolWrite, PendingMfa, PendingMfaSubmit,
22};
23
24/// The header htmx sets on every request it issues.
25const HX_REQUEST: &str = "hx-request";
26
27/// Whether this request came from htmx rather than from the address bar.
28///
29/// Decides both how a redirect is expressed and whether a handler answers with
30/// a whole page or the bare fragment htmx will swap.
31#[must_use]
32pub fn is_htmx(headers: &HeaderMap) -> bool {
33    headers
34        .get(HX_REQUEST)
35        .and_then(|value| value.to_str().ok())
36        .is_some_and(|value| value.eq_ignore_ascii_case("true"))
37}
38
39/// A signed-in operator, on a page that only reads.
40pub struct PageSession {
41    pub auth: Authenticated,
42    /// Carried through so the handler can pick the page or the fragment
43    /// without re-reading the headers it has already given up ownership of.
44    pub hx: bool,
45}
46
47/// A signed-in operator on a page that writes, with the origin and CSRF gates
48/// already passed.
49///
50/// The wrapped [`AuthenticatedWrite`] is what makes that structural: a page
51/// handler cannot reach a session for a mutation by any other route.
52pub struct PageSessionWrite {
53    pub auth: Authenticated,
54    pub hx: bool,
55}
56
57impl FromRequestParts<AdminState> for PageSession {
58    type Rejection = PageError;
59
60    async fn from_request_parts(
61        parts: &mut Parts,
62        state: &AdminState,
63    ) -> Result<Self, Self::Rejection> {
64        let hx = is_htmx(&parts.headers);
65        match Authenticated::from_request_parts(parts, state).await {
66            Ok(auth) => Ok(Self { auth, hx }),
67            Err(error) => Err(to_page_error(error, hx)),
68        }
69    }
70}
71
72impl FromRequestParts<AdminState> for PageSessionWrite {
73    type Rejection = PageError;
74
75    async fn from_request_parts(
76        parts: &mut Parts,
77        state: &AdminState,
78    ) -> Result<Self, Self::Rejection> {
79        let hx = is_htmx(&parts.headers);
80        match AuthenticatedWrite::from_request_parts(parts, state).await {
81            Ok(AuthenticatedWrite(auth)) => Ok(Self { auth, hx }),
82            Err(error) => Err(to_page_error(error, hx)),
83        }
84    }
85}
86
87/// A half-authenticated session, on the page that shows the challenge.
88pub struct PageMfaPending {
89    pub pending: PendingMfa,
90    pub hx: bool,
91}
92
93/// A half-authenticated session, on the form that submits a code.
94///
95/// The origin gate ran and the CSRF check did not -- see [`PendingMfaSubmit`],
96/// which is where that trade is argued.
97pub struct PageMfaSubmit {
98    pub pending: PendingMfa,
99    pub hx: bool,
100}
101
102/// A session allowed to set up a factor, on a page that writes.
103pub struct PageEnrolWrite {
104    pub enrol: EnrolWrite,
105    pub hx: bool,
106}
107
108impl FromRequestParts<AdminState> for PageMfaPending {
109    type Rejection = PageError;
110
111    async fn from_request_parts(
112        parts: &mut Parts,
113        state: &AdminState,
114    ) -> Result<Self, Self::Rejection> {
115        let hx = is_htmx(&parts.headers);
116        match PendingMfa::from_request_parts(parts, state).await {
117            Ok(pending) => Ok(Self { pending, hx }),
118            Err(error) => Err(to_page_error(error, hx)),
119        }
120    }
121}
122
123impl FromRequestParts<AdminState> for PageMfaSubmit {
124    type Rejection = PageError;
125
126    async fn from_request_parts(
127        parts: &mut Parts,
128        state: &AdminState,
129    ) -> Result<Self, Self::Rejection> {
130        let hx = is_htmx(&parts.headers);
131        match PendingMfaSubmit::from_request_parts(parts, state).await {
132            Ok(PendingMfaSubmit(pending)) => Ok(Self { pending, hx }),
133            Err(error) => Err(to_page_error(error, hx)),
134        }
135    }
136}
137
138impl FromRequestParts<AdminState> for PageEnrolWrite {
139    type Rejection = PageError;
140
141    async fn from_request_parts(
142        parts: &mut Parts,
143        state: &AdminState,
144    ) -> Result<Self, Self::Rejection> {
145        let hx = is_htmx(&parts.headers);
146        match EnrolWrite::from_request_parts(parts, state).await {
147            Ok(enrol) => Ok(Self { enrol, hx }),
148            Err(error) => Err(to_page_error(error, hx)),
149        }
150    }
151}
152
153/// A `401` becomes "go and sign in"; anything else keeps its own status.
154///
155/// The distinction matters: a `403 csrf_failed` must *not* bounce the operator
156/// to the sign-in page, because their session is fine and the page would just
157/// send them back — the useful answer is the error, visible where they clicked.
158fn to_page_error(error: AdminError, hx: bool) -> PageError {
159    if error.status == axum::http::StatusCode::UNAUTHORIZED {
160        PageError::login_required(hx)
161    } else {
162        error.into()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use axum::http::StatusCode;
170
171    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
172        let mut map = HeaderMap::new();
173        for (name, value) in pairs {
174            map.insert(
175                axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
176                value.parse().unwrap(),
177            );
178        }
179        map
180    }
181
182    #[test]
183    fn is_htmx_reads_the_header_case_insensitively() {
184        assert!(is_htmx(&headers(&[("hx-request", "true")])));
185        assert!(is_htmx(&headers(&[("HX-Request", "True")])));
186        assert!(!is_htmx(&headers(&[("hx-request", "false")])));
187        // htmx sets it on the boosted-navigation path too, but anything else
188        // in that header is not htmx and must get a real page.
189        assert!(!is_htmx(&headers(&[("hx-request", "yes")])));
190        assert!(!is_htmx(&HeaderMap::new()));
191    }
192
193    #[test]
194    fn an_unauthorized_rejection_becomes_a_sign_in_redirect() {
195        assert_eq!(
196            to_page_error(AdminError::session_expired(), false),
197            PageError::login_required(false)
198        );
199        assert_eq!(
200            to_page_error(AdminError::session_invalid(), true),
201            PageError::login_required(true)
202        );
203    }
204
205    /// A failed CSRF check is not a reason to sign in again — the session is
206    /// live, and bouncing to the sign-in page would send the operator straight
207    /// back with nothing explained.
208    #[test]
209    fn a_csrf_failure_keeps_its_own_status() {
210        let error = to_page_error(AdminError::csrf_failed("no token"), true);
211        assert_eq!(error.status(), StatusCode::FORBIDDEN);
212        assert_eq!(
213            error,
214            PageError::Rendered {
215                status: StatusCode::FORBIDDEN,
216                code: "csrf_failed",
217                message: "no token".to_string(),
218            }
219        );
220    }
221}