acme_proxy/webadmin/pages/
auth.rs1use 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
24const HX_REQUEST: &str = "hx-request";
26
27#[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
39pub struct PageSession {
41 pub auth: Authenticated,
42 pub hx: bool,
45}
46
47pub 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
87pub struct PageMfaPending {
89 pub pending: PendingMfa,
90 pub hx: bool,
91}
92
93pub struct PageMfaSubmit {
98 pub pending: PendingMfa,
99 pub hx: bool,
100}
101
102pub 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
153fn 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 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 #[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}