Skip to main content

acme_proxy_admin/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    AdminRead, AdminWrite, Authenticated, AuthenticatedWrite, EnrolWrite, PendingMfa,
22    PendingMfaSubmit, SelfServiceWrite,
23};
24
25/// The header htmx sets on every request it issues.
26const HX_REQUEST: &str = "hx-request";
27
28/// Whether this request came from htmx rather than from the address bar.
29///
30/// Decides both how a redirect is expressed and whether a handler answers with
31/// a whole page or the bare fragment htmx will swap.
32#[must_use]
33pub fn is_htmx(headers: &HeaderMap) -> bool {
34    headers
35        .get(HX_REQUEST)
36        .and_then(|value| value.to_str().ok())
37        .is_some_and(|value| value.eq_ignore_ascii_case("true"))
38}
39
40/// A signed-in operator, on a page that only reads.
41pub struct PageSession {
42    pub auth: Authenticated,
43    /// Carried through so the handler can pick the page or the fragment
44    /// without re-reading the headers it has already given up ownership of.
45    pub hx: bool,
46}
47
48/// A signed-in operator on a page that writes, with the origin and CSRF gates
49/// already passed.
50///
51/// The wrapped [`AuthenticatedWrite`] is what makes that structural: a page
52/// handler cannot reach a session for a mutation by any other route.
53pub struct PageSessionWrite {
54    pub auth: Authenticated,
55    pub hx: bool,
56}
57
58impl FromRequestParts<AdminState> for PageSession {
59    type Rejection = PageError;
60
61    async fn from_request_parts(
62        parts: &mut Parts,
63        state: &AdminState,
64    ) -> Result<Self, Self::Rejection> {
65        let hx = is_htmx(&parts.headers);
66        match Authenticated::from_request_parts(parts, state).await {
67            Ok(auth) => Ok(Self { auth, hx }),
68            Err(error) => Err(to_page_error(error, hx)),
69        }
70    }
71}
72
73impl FromRequestParts<AdminState> for PageSessionWrite {
74    type Rejection = PageError;
75
76    async fn from_request_parts(
77        parts: &mut Parts,
78        state: &AdminState,
79    ) -> Result<Self, Self::Rejection> {
80        let hx = is_htmx(&parts.headers);
81        match AuthenticatedWrite::from_request_parts(parts, state).await {
82            Ok(AuthenticatedWrite(auth)) => Ok(Self { auth, hx }),
83            Err(error) => Err(to_page_error(error, hx)),
84        }
85    }
86}
87
88/// A signed-in **admin** on a page that manages other operators, with the
89/// origin and CSRF gates passed. Wraps [`AdminWrite`].
90pub struct PageAdminWrite {
91    pub auth: Authenticated,
92    pub hx: bool,
93}
94
95/// A signed-in operator of **any** role, on a page that only touches their own
96/// account. Wraps [`SelfServiceWrite`].
97pub struct PageSelfServiceWrite {
98    pub auth: Authenticated,
99    pub hx: bool,
100}
101
102/// A signed-in **admin** on a page that only *reads* other operators. Wraps
103/// [`AdminRead`], so there is no CSRF or origin gate — see that type for why
104/// the `/ui/operators` reads are tiered at all.
105pub struct PageAdminRead {
106    pub auth: Authenticated,
107    pub hx: bool,
108}
109
110/// What `pages::chrome` needs of a page extractor: the session and the operator
111/// behind it.
112///
113/// A trait rather than a concrete `&PageSession` parameter, because a read
114/// route may be gated on a tier ([`PageAdminRead`]) and still owe the same
115/// navigation, CSRF token and user card as an ungated one. Only the read-side
116/// extractors implement it — a write extractor never renders chrome, since its
117/// answer is a fragment or a redirect.
118pub trait PageAuth {
119    fn auth(&self) -> &Authenticated;
120}
121
122impl PageAuth for PageSession {
123    fn auth(&self) -> &Authenticated {
124        &self.auth
125    }
126}
127
128impl PageAuth for PageAdminRead {
129    fn auth(&self) -> &Authenticated {
130        &self.auth
131    }
132}
133
134impl FromRequestParts<AdminState> for PageAdminWrite {
135    type Rejection = PageError;
136
137    async fn from_request_parts(
138        parts: &mut Parts,
139        state: &AdminState,
140    ) -> Result<Self, Self::Rejection> {
141        let hx = is_htmx(&parts.headers);
142        match AdminWrite::from_request_parts(parts, state).await {
143            Ok(AdminWrite(auth)) => Ok(Self { auth, hx }),
144            Err(error) => Err(to_page_error(error, hx)),
145        }
146    }
147}
148
149impl FromRequestParts<AdminState> for PageAdminRead {
150    type Rejection = PageError;
151
152    async fn from_request_parts(
153        parts: &mut Parts,
154        state: &AdminState,
155    ) -> Result<Self, Self::Rejection> {
156        let hx = is_htmx(&parts.headers);
157        match AdminRead::from_request_parts(parts, state).await {
158            Ok(AdminRead(auth)) => Ok(Self { auth, hx }),
159            Err(error) => Err(to_page_error(error, hx)),
160        }
161    }
162}
163
164impl FromRequestParts<AdminState> for PageSelfServiceWrite {
165    type Rejection = PageError;
166
167    async fn from_request_parts(
168        parts: &mut Parts,
169        state: &AdminState,
170    ) -> Result<Self, Self::Rejection> {
171        let hx = is_htmx(&parts.headers);
172        match SelfServiceWrite::from_request_parts(parts, state).await {
173            Ok(SelfServiceWrite(auth)) => Ok(Self { auth, hx }),
174            Err(error) => Err(to_page_error(error, hx)),
175        }
176    }
177}
178
179/// A half-authenticated session, on the page that shows the challenge.
180pub struct PageMfaPending {
181    pub pending: PendingMfa,
182    pub hx: bool,
183}
184
185/// A half-authenticated session, on the form that submits a code.
186///
187/// The origin gate ran and the CSRF check did not -- see [`PendingMfaSubmit`],
188/// which is where that trade is argued.
189pub struct PageMfaSubmit {
190    pub pending: PendingMfa,
191    pub hx: bool,
192}
193
194/// A session allowed to set up a factor, on a page that writes.
195pub struct PageEnrolWrite {
196    pub enrol: EnrolWrite,
197    pub hx: bool,
198}
199
200impl FromRequestParts<AdminState> for PageMfaPending {
201    type Rejection = PageError;
202
203    async fn from_request_parts(
204        parts: &mut Parts,
205        state: &AdminState,
206    ) -> Result<Self, Self::Rejection> {
207        let hx = is_htmx(&parts.headers);
208        match PendingMfa::from_request_parts(parts, state).await {
209            Ok(pending) => Ok(Self { pending, hx }),
210            Err(error) => Err(to_page_error(error, hx)),
211        }
212    }
213}
214
215impl FromRequestParts<AdminState> for PageMfaSubmit {
216    type Rejection = PageError;
217
218    async fn from_request_parts(
219        parts: &mut Parts,
220        state: &AdminState,
221    ) -> Result<Self, Self::Rejection> {
222        let hx = is_htmx(&parts.headers);
223        match PendingMfaSubmit::from_request_parts(parts, state).await {
224            Ok(PendingMfaSubmit(pending)) => Ok(Self { pending, hx }),
225            Err(error) => Err(to_page_error(error, hx)),
226        }
227    }
228}
229
230impl FromRequestParts<AdminState> for PageEnrolWrite {
231    type Rejection = PageError;
232
233    async fn from_request_parts(
234        parts: &mut Parts,
235        state: &AdminState,
236    ) -> Result<Self, Self::Rejection> {
237        let hx = is_htmx(&parts.headers);
238        match EnrolWrite::from_request_parts(parts, state).await {
239            Ok(enrol) => Ok(Self { enrol, hx }),
240            Err(error) => Err(to_page_error(error, hx)),
241        }
242    }
243}
244
245/// A `401` becomes "go and sign in"; anything else keeps its own status.
246///
247/// The distinction matters: a `403 csrf_failed` must *not* bounce the operator
248/// to the sign-in page, because their session is fine and the page would just
249/// send them back — the useful answer is the error, visible where they clicked.
250fn to_page_error(error: AdminError, hx: bool) -> PageError {
251    if error.status == axum::http::StatusCode::UNAUTHORIZED {
252        PageError::login_required(hx)
253    } else {
254        error.into()
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use axum::http::StatusCode;
262
263    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
264        let mut map = HeaderMap::new();
265        for (name, value) in pairs {
266            map.insert(
267                axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
268                value.parse().unwrap(),
269            );
270        }
271        map
272    }
273
274    #[test]
275    fn is_htmx_reads_the_header_case_insensitively() {
276        assert!(is_htmx(&headers(&[("hx-request", "true")])));
277        assert!(is_htmx(&headers(&[("HX-Request", "True")])));
278        assert!(!is_htmx(&headers(&[("hx-request", "false")])));
279        // htmx sets it on the boosted-navigation path too, but anything else
280        // in that header is not htmx and must get a real page.
281        assert!(!is_htmx(&headers(&[("hx-request", "yes")])));
282        assert!(!is_htmx(&HeaderMap::new()));
283    }
284
285    #[test]
286    fn an_unauthorized_rejection_becomes_a_sign_in_redirect() {
287        assert_eq!(
288            to_page_error(AdminError::session_expired(), false),
289            PageError::login_required(false)
290        );
291        assert_eq!(
292            to_page_error(AdminError::session_invalid(), true),
293            PageError::login_required(true)
294        );
295    }
296
297    /// A failed CSRF check is not a reason to sign in again — the session is
298    /// live, and bouncing to the sign-in page would send the operator straight
299    /// back with nothing explained.
300    #[test]
301    fn a_csrf_failure_keeps_its_own_status() {
302        let error = to_page_error(AdminError::csrf_failed("no token"), true);
303        assert_eq!(error.status(), StatusCode::FORBIDDEN);
304        assert_eq!(
305            error,
306            PageError::Rendered {
307                status: StatusCode::FORBIDDEN,
308                code: "csrf_failed",
309                message: "no token".to_string(),
310            }
311        );
312    }
313}