1use std::sync::Arc;
2
3use axum::Form;
4use axum::Router;
5use axum::extract::{Extension, Query};
6use axum::http::HeaderMap;
7use axum::http::StatusCode;
8use axum::http::Uri;
9use axum::http::header::{LOCATION, SET_COOKIE, USER_AGENT};
10use axum::response::{IntoResponse, Response};
11use axum::routing::{get, post};
12use axum_htmx::{HxBoosted, HxRequest};
13use chrono::Utc;
14use minijinja::{Environment, context};
15use serde::Deserialize;
16
17use allowthem_core::applications::BrandingConfig;
18use allowthem_core::totp::totp_uri;
19use allowthem_core::{AllowThem, AuditEvent, AuthError, sessions};
20use qrcode::QrCode;
21use qrcode::render::svg;
22
23use crate::branding::{DefaultBranding, branding_context, default_branding_ref, resolve_branding};
24use crate::browser_error::BrowserError;
25use crate::csrf::CsrfToken;
26use crate::error::BrowserAuthRedirect;
27
28const SETUP_INVALID_CODE: &str = "Invalid TOTP code";
30
31const CHALLENGE_INVALID_TOTP: &str = "Invalid TOTP or recovery code";
33
34const CHALLENGE_INVALID_RECOVERY: &str = "Invalid recovery code";
36
37#[derive(Clone)]
38struct MfaPageConfig {
39 templates: Arc<Environment<'static>>,
40 is_production: bool,
41 base_url: String,
42}
43
44fn client_ip(headers: &HeaderMap) -> Option<String> {
49 headers
50 .get("x-forwarded-for")
51 .and_then(|v| v.to_str().ok())
52 .and_then(|s| s.split(',').next())
53 .map(|s| s.trim().to_string())
54}
55
56fn qr_data_uri(text: &str) -> String {
62 let code = match QrCode::new(text.as_bytes()) {
63 Ok(c) => c,
64 Err(_) => return String::new(),
65 };
66 let svg_str = code
67 .render()
68 .min_dimensions(200, 200)
69 .dark_color(svg::Color("#000000"))
70 .light_color(svg::Color("#ffffff"))
71 .build();
72 let encoded = svg_str
74 .replace('#', "%23")
75 .replace('<', "%3C")
76 .replace('>', "%3E")
77 .replace('"', "'");
78 format!("data:image/svg+xml,{encoded}")
79}
80
81fn derive_issuer(base_url: &str) -> String {
86 base_url
87 .trim_start_matches("https://")
88 .trim_start_matches("http://")
89 .split('/')
90 .next()
91 .unwrap_or("allowthem")
92 .split(':')
93 .next()
94 .unwrap_or("allowthem")
95 .to_string()
96}
97
98async fn require_browser_user(
104 ath: &AllowThem,
105 headers: &HeaderMap,
106 path: &str,
107) -> Result<allowthem_core::types::User, Response> {
108 let cookie_header = headers
109 .get(axum::http::header::COOKIE)
110 .and_then(|v| v.to_str().ok())
111 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
112
113 let token = ath
114 .parse_session_cookie(cookie_header)
115 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
116
117 let ttl = ath.session_config().ttl;
118 let session = ath
119 .db()
120 .validate_session(&token, ttl)
121 .await
122 .map_err(|err| {
123 tracing::error!("session validation error: {err}");
124 BrowserAuthRedirect::new(path).into_response()
125 })?
126 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
127
128 match ath.db().get_user(session.user_id).await {
129 Ok(user) if user.is_active => Ok(user),
130 Ok(_) => Err(BrowserAuthRedirect::new(path).into_response()),
131 Err(AuthError::NotFound) => Err(BrowserAuthRedirect::new(path).into_response()),
132 Err(err) => {
133 tracing::error!("user lookup error: {err}");
134 Err(BrowserAuthRedirect::new(path).into_response())
135 }
136 }
137}
138
139fn render_mfa_setup_fragment(
151 config: &MfaPageConfig,
152 csrf_token: &str,
153 totp_uri: &str,
154 qr_data_uri: &str,
155 secret: &str,
156 error: &str,
157 branding: Option<&BrandingConfig>,
158) -> Result<axum::response::Html<String>, BrowserError> {
159 let ctx = context! {
160 csrf_token,
161 totp_uri,
162 qr_data_uri,
163 secret,
164 error,
165 is_production => config.is_production,
166 page_title => "Set up two-factor authentication — allowthem",
167 status_hint => "ENABLE 2FA",
168 ..branding_context(branding),
169 };
170
171 let main = crate::browser_templates::render(
172 &config.templates,
173 "_partials/_auth_main_mfa_setup.html",
174 ctx.clone(),
175 )?;
176 let oob =
177 crate::browser_templates::render(&config.templates, "_partials/_auth_oob_head.html", ctx)?;
178 Ok(axum::response::Html(format!("{}{}", main.0, oob.0)))
179}
180
181fn render_mfa_recovery_fragment(
184 config: &MfaPageConfig,
185 recovery_codes: &[String],
186 branding: Option<&BrandingConfig>,
187) -> Result<axum::response::Html<String>, BrowserError> {
188 let ctx = context! {
189 recovery_codes,
190 is_production => config.is_production,
191 page_title => "Recovery codes — allowthem",
192 status_hint => "RECOVERY CODES",
193 ..branding_context(branding),
194 };
195
196 let main = crate::browser_templates::render(
197 &config.templates,
198 "_partials/_auth_main_mfa_recovery.html",
199 ctx.clone(),
200 )?;
201 let oob =
202 crate::browser_templates::render(&config.templates, "_partials/_auth_oob_head.html", ctx)?;
203 Ok(axum::response::Html(format!("{}{}", main.0, oob.0)))
204}
205
206#[allow(clippy::too_many_arguments)]
211async fn get_mfa_setup(
212 Extension(ath): Extension<AllowThem>,
213 Extension(config): Extension<MfaPageConfig>,
214 default_branding: Option<Extension<Arc<DefaultBranding>>>,
215 uri: Uri,
216 csrf: CsrfToken,
217 headers: HeaderMap,
218 HxBoosted(boosted): HxBoosted,
219 HxRequest(request): HxRequest,
220) -> Result<Response, BrowserError> {
221 let user = match require_browser_user(&ath, &headers, uri.path()).await {
222 Ok(u) => u,
223 Err(redirect) => return Ok(redirect),
224 };
225
226 let default = default_branding_ref(&default_branding);
227 let branding = resolve_branding(&ath, None, default).await;
228
229 let secret = match ath.get_pending_mfa_secret(user.id).await? {
231 Some(s) => s,
232 None => ath.create_mfa_secret(user.id).await?,
233 };
234
235 let issuer = derive_issuer(&config.base_url);
236 let uri = totp_uri(&secret, user.email.as_str(), &issuer);
237 let qr = qr_data_uri(&uri);
238
239 if request && !boosted {
240 let html = render_mfa_setup_fragment(
241 &config,
242 csrf.as_str(),
243 &uri,
244 &qr,
245 &secret,
246 "",
247 branding.as_ref(),
248 )?;
249 return Ok(html.into_response());
250 }
251
252 let html = crate::browser_templates::render(
253 &config.templates,
254 "mfa_setup.html",
255 context! {
256 csrf_token => csrf.as_str(),
257 secret => &secret,
258 totp_uri => &uri,
259 qr_data_uri => &qr,
260 error => "",
261 is_production => config.is_production,
262 ..branding_context(branding.as_ref()),
263 },
264 )?;
265 Ok(html.into_response())
266}
267
268#[derive(Deserialize)]
269pub struct MfaConfirmForm {
270 code: String,
271 #[allow(dead_code)]
272 csrf_token: String,
273}
274
275#[allow(clippy::too_many_arguments)]
280async fn post_mfa_confirm(
281 Extension(ath): Extension<AllowThem>,
282 Extension(config): Extension<MfaPageConfig>,
283 default_branding: Option<Extension<Arc<DefaultBranding>>>,
284 uri: Uri,
285 csrf: CsrfToken,
286 headers: HeaderMap,
287 HxBoosted(boosted): HxBoosted,
288 HxRequest(request): HxRequest,
289 Form(form): Form<MfaConfirmForm>,
290) -> Result<Response, BrowserError> {
291 let user = match require_browser_user(&ath, &headers, uri.path()).await {
292 Ok(u) => u,
293 Err(redirect) => return Ok(redirect),
294 };
295
296 let default = default_branding_ref(&default_branding);
297 let branding = resolve_branding(&ath, None, default).await;
298
299 let ip = client_ip(&headers);
300 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
301
302 match ath.enable_mfa(user.id, &form.code).await {
303 Ok(recovery_codes) => {
304 let _ = ath
305 .db()
306 .log_audit(
307 AuditEvent::MfaEnabled,
308 Some(&user.id),
309 None,
310 ip.as_deref(),
311 ua,
312 None,
313 )
314 .await;
315
316 if request && !boosted {
317 let html =
318 render_mfa_recovery_fragment(&config, &recovery_codes, branding.as_ref())?;
319 return Ok(html.into_response());
320 }
321
322 let html = crate::browser_templates::render(
323 &config.templates,
324 "mfa_recovery.html",
325 context! {
326 recovery_codes => &recovery_codes,
327 is_production => config.is_production,
328 ..branding_context(branding.as_ref()),
329 },
330 )?;
331 Ok(html.into_response())
332 }
333 Err(allowthem_core::AuthError::InvalidTotpCode) => {
334 let secret = ath
336 .get_pending_mfa_secret(user.id)
337 .await?
338 .unwrap_or_default();
339 let issuer = derive_issuer(&config.base_url);
340 let uri = totp_uri(&secret, user.email.as_str(), &issuer);
341 let qr = qr_data_uri(&uri);
342
343 let html = crate::browser_templates::render(
344 &config.templates,
345 "mfa_setup.html",
346 context! {
347 csrf_token => csrf.as_str(),
348 secret => &secret,
349 totp_uri => &uri,
350 qr_data_uri => &qr,
351 error => SETUP_INVALID_CODE,
352 is_production => config.is_production,
353 ..branding_context(branding.as_ref()),
354 },
355 )?;
356 Ok(html.into_response())
357 }
358 Err(e) => Err(BrowserError::Auth(e)),
359 }
360}
361
362#[derive(Deserialize)]
363pub struct MfaDisableForm {
364 #[allow(dead_code)]
365 csrf_token: String,
366}
367
368async fn post_mfa_disable(
370 Extension(ath): Extension<AllowThem>,
371 uri: Uri,
372 headers: HeaderMap,
373 Form(_form): Form<MfaDisableForm>,
374) -> Result<Response, BrowserError> {
375 let user = match require_browser_user(&ath, &headers, uri.path()).await {
376 Ok(u) => u,
377 Err(redirect) => return Ok(redirect),
378 };
379
380 let ip = client_ip(&headers);
381 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
382
383 ath.disable_mfa(user.id).await?;
384
385 let _ = ath
386 .db()
387 .log_audit(
388 AuditEvent::MfaDisabled,
389 Some(&user.id),
390 None,
391 ip.as_deref(),
392 ua,
393 None,
394 )
395 .await;
396
397 Ok((StatusCode::SEE_OTHER, [(LOCATION, "/settings".to_string())]).into_response())
398}
399
400#[derive(Deserialize)]
401struct RegenerateCodesForm {
402 #[allow(dead_code)]
403 csrf_token: String,
404}
405
406#[allow(clippy::too_many_arguments)]
408async fn post_regenerate_recovery_codes(
409 Extension(ath): Extension<AllowThem>,
410 Extension(config): Extension<MfaPageConfig>,
411 default_branding: Option<Extension<Arc<DefaultBranding>>>,
412 uri: Uri,
413 headers: HeaderMap,
414 HxBoosted(boosted): HxBoosted,
415 HxRequest(request): HxRequest,
416 Form(_form): Form<RegenerateCodesForm>,
417) -> Result<Response, BrowserError> {
418 let user = match require_browser_user(&ath, &headers, uri.path()).await {
419 Ok(u) => u,
420 Err(redirect) => return Ok(redirect),
421 };
422
423 let has_mfa = ath.db().has_mfa_enabled(user.id).await?;
424 if !has_mfa {
425 return Ok((StatusCode::SEE_OTHER, [(LOCATION, "/settings".to_string())]).into_response());
426 }
427
428 let recovery_codes = ath.regenerate_recovery_codes(user.id).await?;
429
430 let default = default_branding_ref(&default_branding);
431 let branding = resolve_branding(&ath, None, default).await;
432
433 if request && !boosted {
434 let html = render_mfa_recovery_fragment(&config, &recovery_codes, branding.as_ref())?;
435 return Ok(html.into_response());
436 }
437
438 let html = crate::browser_templates::render(
439 &config.templates,
440 "mfa_recovery.html",
441 context! {
442 recovery_codes => &recovery_codes,
443 is_production => config.is_production,
444 ..branding_context(branding.as_ref()),
445 },
446 )?;
447 Ok(html.into_response())
448}
449
450#[derive(Deserialize)]
455pub struct ChallengeQuery {
456 token: String,
457}
458
459fn render_mfa_challenge_fragment(
468 config: &MfaPageConfig,
469 mfa_token: &str,
470 error: &str,
471 branding: Option<&BrandingConfig>,
472) -> Result<axum::response::Html<String>, BrowserError> {
473 let ctx = context! {
474 mfa_token,
475 error,
476 is_production => config.is_production,
477 page_title => "Two-factor authentication — allowthem",
478 status_hint => "TWO-FACTOR",
479 ..branding_context(branding),
480 };
481
482 let main = crate::browser_templates::render(
483 &config.templates,
484 "_partials/_auth_main_mfa_challenge.html",
485 ctx.clone(),
486 )?;
487 let oob =
488 crate::browser_templates::render(&config.templates, "_partials/_auth_oob_head.html", ctx)?;
489 Ok(axum::response::Html(format!("{}{}", main.0, oob.0)))
490}
491
492async fn get_mfa_challenge(
494 Extension(ath): Extension<AllowThem>,
495 Extension(config): Extension<MfaPageConfig>,
496 default_branding: Option<Extension<Arc<DefaultBranding>>>,
497 Query(query): Query<ChallengeQuery>,
498 HxBoosted(boosted): HxBoosted,
499 HxRequest(request): HxRequest,
500) -> Result<Response, BrowserError> {
501 let user_id = ath.db().validate_mfa_challenge(&query.token).await?;
503 if user_id.is_none() {
504 return Ok((StatusCode::SEE_OTHER, [(LOCATION, "/login".to_string())]).into_response());
506 }
507
508 let default = default_branding_ref(&default_branding);
509 let branding = resolve_branding(&ath, None, default).await;
510
511 if request && !boosted {
512 let html = render_mfa_challenge_fragment(&config, &query.token, "", branding.as_ref())?;
513 return Ok(html.into_response());
514 }
515
516 let html = crate::browser_templates::render(
517 &config.templates,
518 "mfa_challenge.html",
519 context! {
520 mfa_token => &query.token,
521 error => "",
522 is_production => config.is_production,
523 ..branding_context(branding.as_ref()),
524 },
525 )?;
526 Ok(html.into_response())
527}
528
529#[derive(Deserialize)]
530pub struct MfaChallengeForm {
531 mfa_token: String,
532 #[serde(default)]
533 code: Option<String>,
534 #[serde(default)]
535 recovery_code: Option<String>,
536 #[serde(default)]
537 use_recovery: Option<String>,
538}
539
540async fn post_mfa_challenge(
542 Extension(ath): Extension<AllowThem>,
543 Extension(config): Extension<MfaPageConfig>,
544 default_branding: Option<Extension<Arc<DefaultBranding>>>,
545 headers: HeaderMap,
546 Form(form): Form<MfaChallengeForm>,
547) -> Result<Response, BrowserError> {
548 let default = default_branding_ref(&default_branding);
549 let branding = resolve_branding(&ath, None, default).await;
550 let ip = headers
551 .get("x-forwarded-for")
552 .and_then(|v| v.to_str().ok())
553 .and_then(|s| s.split(',').next())
554 .map(|s| s.trim().to_string());
555 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
556
557 let user_id = match ath.db().validate_mfa_challenge(&form.mfa_token).await? {
559 Some(uid) => uid,
560 None => {
561 return Ok((StatusCode::SEE_OTHER, [(LOCATION, "/login".to_string())]).into_response());
562 }
563 };
564
565 let use_recovery = form.use_recovery.is_some();
567 let verified = if use_recovery {
568 let code = form.recovery_code.as_deref().unwrap_or("");
569 ath.verify_recovery_code(user_id, code).await?
570 } else {
571 let code = form.code.as_deref().unwrap_or("");
572 ath.verify_totp(user_id, code).await?
573 };
574
575 if !verified {
576 let _ = ath
578 .db()
579 .log_audit(
580 AuditEvent::MfaChallengeFailed,
581 Some(&user_id),
582 None,
583 ip.as_deref(),
584 ua,
585 None,
586 )
587 .await;
588
589 let error_msg = if use_recovery {
590 CHALLENGE_INVALID_RECOVERY
591 } else {
592 CHALLENGE_INVALID_TOTP
593 };
594
595 let html = crate::browser_templates::render(
596 &config.templates,
597 "mfa_challenge.html",
598 context! {
599 mfa_token => &form.mfa_token,
600 error => error_msg,
601 is_production => config.is_production,
602 ..branding_context(branding.as_ref()),
603 },
604 )?;
605 return Ok(html.into_response());
606 }
607
608 ath.db().consume_mfa_challenge(&form.mfa_token).await?;
610
611 let _ = ath
612 .db()
613 .log_audit(
614 AuditEvent::MfaChallengeSuccess,
615 Some(&user_id),
616 None,
617 ip.as_deref(),
618 ua,
619 None,
620 )
621 .await;
622
623 let _ = ath
626 .db()
627 .log_audit(
628 AuditEvent::Login,
629 Some(&user_id),
630 None,
631 ip.as_deref(),
632 ua,
633 None,
634 )
635 .await;
636
637 let token = sessions::generate_token();
638 let token_hash = sessions::hash_token(&token);
639 let ttl = ath.session_config().ttl;
640 let expires_at = Utc::now() + ttl;
641 ath.db()
642 .create_session(user_id, token_hash, ip.as_deref(), ua, expires_at)
643 .await?;
644
645 ath.notify_user_active(user_id);
646 ath.emit_event(allowthem_core::AuthEvent::new(
647 "session.created",
648 Some(user_id),
649 serde_json::json!({ "user_id": user_id }),
650 ))
651 .await;
652
653 let cookie = ath.session_cookie(&token);
654
655 Ok((
656 StatusCode::SEE_OTHER,
657 [(SET_COOKIE, cookie), (LOCATION, "/".to_string())],
658 )
659 .into_response())
660}
661
662pub fn mfa_setup_routes(
673 templates: Arc<Environment<'static>>,
674 is_production: bool,
675 base_url: String,
676) -> Router<()> {
677 let cfg = MfaPageConfig {
678 templates,
679 is_production,
680 base_url,
681 };
682 Router::new()
683 .route("/settings/mfa/setup", get(get_mfa_setup))
684 .route("/settings/mfa/confirm", post(post_mfa_confirm))
685 .route("/settings/mfa/disable", post(post_mfa_disable))
686 .route(
687 "/settings/mfa/recovery-codes/regenerate",
688 post(post_regenerate_recovery_codes),
689 )
690 .layer(Extension(cfg))
691}
692
693pub fn mfa_challenge_routes(
699 templates: Arc<Environment<'static>>,
700 is_production: bool,
701) -> Router<()> {
702 let cfg = MfaPageConfig {
703 templates,
704 is_production,
705 base_url: String::new(),
706 };
707 Router::new()
708 .route(
709 "/mfa/challenge",
710 get(get_mfa_challenge).post(post_mfa_challenge),
711 )
712 .layer(Extension(cfg))
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 use axum::body::Body;
720 use axum::http::{Request, StatusCode, header};
721 use chrono::{Duration, Utc};
722 use totp_rs::{Algorithm, Secret, TOTP};
723 use tower::ServiceExt;
724
725 use allowthem_core::{AllowThemBuilder, Email, generate_token, hash_token};
726
727 const TEST_MFA_KEY: [u8; 32] = [0x42; 32];
728
729 async fn setup() -> AllowThem {
734 AllowThemBuilder::new("sqlite::memory:")
735 .cookie_secure(false)
736 .mfa_key(TEST_MFA_KEY)
737 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
738 .build()
739 .await
740 .unwrap()
741 }
742
743 fn test_app(ath: AllowThem) -> Router {
746 let templates = crate::browser_templates::build_default_browser_env();
747 Router::new()
748 .merge(mfa_setup_routes(
749 templates.clone(),
750 false,
751 "http://127.0.0.1:3100".into(),
752 ))
753 .layer(axum::middleware::from_fn(crate::csrf::csrf_middleware))
754 .merge(mfa_challenge_routes(templates, false))
755 .layer(axum::middleware::from_fn_with_state(
756 ath.clone(),
757 crate::cors::inject_ath_into_extensions,
758 ))
759 }
760
761 async fn create_session(ath: &AllowThem) -> (allowthem_core::types::UserId, String) {
762 let email = Email::new("mfa-test@example.com".into()).unwrap();
763 let user = ath
764 .db()
765 .create_user(email, "pass", None, None)
766 .await
767 .unwrap();
768 let token = generate_token();
769 let token_hash = hash_token(&token);
770 let expires = Utc::now() + Duration::hours(24);
771 ath.db()
772 .create_session(user.id, token_hash, None, None, expires)
773 .await
774 .unwrap();
775 let cookie = ath.session_cookie(&token);
776 let cookie_val = cookie.split(';').next().unwrap().to_string();
777 (user.id, cookie_val)
778 }
779
780 async fn get_csrf(app: &Router, session_cookie: &str) -> String {
782 let req = Request::builder()
783 .uri("/settings/mfa/setup")
784 .header(header::COOKIE, session_cookie)
785 .body(Body::empty())
786 .unwrap();
787 let resp = app.clone().oneshot(req).await.unwrap();
788 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
789 .await
790 .unwrap();
791 let html = String::from_utf8(bytes.to_vec()).unwrap();
792 let marker = "name=\"csrf_token\" value=\"";
793 let start = html.find(marker).expect("csrf_token not found in HTML") + marker.len();
794 let end = html[start..].find('"').unwrap() + start;
795 html[start..end].to_string()
796 }
797
798 async fn enable_mfa_for_user(
800 ath: &AllowThem,
801 user_id: allowthem_core::types::UserId,
802 ) -> (TOTP, Vec<String>) {
803 let secret_b32 = ath.create_mfa_secret(user_id).await.unwrap();
804 let totp = TOTP::new(
805 Algorithm::SHA1,
806 6,
807 1,
808 30,
809 Secret::Encoded(secret_b32).to_bytes().unwrap(),
810 None,
811 String::new(),
812 )
813 .unwrap();
814 let code = totp.generate_current().unwrap();
815 let recovery_codes = ath.enable_mfa(user_id, &code).await.unwrap();
816 (totp, recovery_codes)
817 }
818
819 #[test]
824 fn qr_data_uri_produces_svg_data_uri() {
825 let uri = qr_data_uri("otpauth://totp/test?secret=ABC&issuer=test");
826 assert!(
827 uri.starts_with("data:image/svg+xml,"),
828 "must produce an SVG data URI"
829 );
830 assert!(uri.contains("svg"), "must contain SVG content");
831 assert!(
834 !uri.contains('&'),
835 "data URI must not contain raw '&' characters"
836 );
837 }
838
839 #[test]
840 fn qr_data_uri_empty_input_still_works() {
841 let uri = qr_data_uri("");
842 assert!(uri.starts_with("data:image/svg+xml,"));
844 }
845
846 #[test]
851 fn derive_issuer_strips_http_scheme() {
852 assert_eq!(derive_issuer("http://example.com"), "example.com");
853 }
854
855 #[test]
856 fn derive_issuer_strips_https_scheme() {
857 assert_eq!(
858 derive_issuer("https://auth.example.com"),
859 "auth.example.com"
860 );
861 }
862
863 #[test]
864 fn derive_issuer_strips_port() {
865 assert_eq!(derive_issuer("http://127.0.0.1:3100"), "127.0.0.1");
867 }
868
869 #[test]
870 fn derive_issuer_strips_path() {
871 assert_eq!(
872 derive_issuer("https://auth.example.com/some/path"),
873 "auth.example.com"
874 );
875 }
876
877 #[tokio::test]
882 async fn get_mfa_setup_renders_secret() {
883 let ath = setup().await;
884 let app = test_app(ath.clone());
885 let (_, cookie) = create_session(&ath).await;
886
887 let csrf = get_csrf(&app, &cookie).await;
888 let req = Request::builder()
889 .uri("/settings/mfa/setup")
890 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
891 .body(Body::empty())
892 .unwrap();
893 let resp = app.oneshot(req).await.unwrap();
894
895 assert_eq!(resp.status(), StatusCode::OK);
896 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
897 .await
898 .unwrap();
899 let html = String::from_utf8(body.to_vec()).unwrap();
900 assert!(
901 html.contains("totp-secret"),
902 "setup page must show secret element"
903 );
904 assert!(
906 html.contains("totp-uri"),
907 "setup page must show QR URI container"
908 );
909 assert!(
910 html.contains("data:image/svg+xml,"),
911 "setup page must include a QR code data URI"
912 );
913 }
914
915 #[tokio::test]
916 async fn get_mfa_setup_is_idempotent() {
917 let ath = setup().await;
919 let app = test_app(ath.clone());
920 let (_, cookie) = create_session(&ath).await;
921 let csrf = get_csrf(&app, &cookie).await;
922
923 let secret_of = |html: String| -> String {
924 let after_attr = html
929 .split("data-testid=\"totp-secret\"")
930 .nth(1)
931 .expect("totp-secret element not found in HTML");
932 let after_tag_close = after_attr
933 .splitn(2, '>')
934 .nth(1)
935 .expect("closing > of totp-secret element not found");
936 after_tag_close.split('<').next().unwrap_or("").to_string()
937 };
938
939 let req1 = Request::builder()
940 .uri("/settings/mfa/setup")
941 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
942 .body(Body::empty())
943 .unwrap();
944 let resp1 = app.clone().oneshot(req1).await.unwrap();
945 let html1 = String::from_utf8(
946 axum::body::to_bytes(resp1.into_body(), usize::MAX)
947 .await
948 .unwrap()
949 .to_vec(),
950 )
951 .unwrap();
952
953 let req2 = Request::builder()
954 .uri("/settings/mfa/setup")
955 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
956 .body(Body::empty())
957 .unwrap();
958 let resp2 = app.clone().oneshot(req2).await.unwrap();
959 let html2 = String::from_utf8(
960 axum::body::to_bytes(resp2.into_body(), usize::MAX)
961 .await
962 .unwrap()
963 .to_vec(),
964 )
965 .unwrap();
966
967 assert_eq!(
968 secret_of(html1),
969 secret_of(html2),
970 "repeated GET /settings/mfa/setup must return the same pending secret"
971 );
972 }
973
974 #[tokio::test]
979 async fn post_mfa_confirm_invalid_code_shows_error_and_does_not_enable() {
980 let ath = setup().await;
981 let app = test_app(ath.clone());
982 let (user_id, cookie) = create_session(&ath).await;
983
984 let csrf = get_csrf(&app, &cookie).await;
986
987 let body_str = format!("code=000000&csrf_token={csrf}");
988 let req = Request::builder()
989 .method("POST")
990 .uri("/settings/mfa/confirm")
991 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
992 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
993 .body(Body::from(body_str))
994 .unwrap();
995 let resp = app.oneshot(req).await.unwrap();
996
997 assert_eq!(resp.status(), StatusCode::OK);
998 let html = String::from_utf8(
999 axum::body::to_bytes(resp.into_body(), usize::MAX)
1000 .await
1001 .unwrap()
1002 .to_vec(),
1003 )
1004 .unwrap();
1005 assert!(
1006 html.contains(SETUP_INVALID_CODE),
1007 "wrong code must show setup error"
1008 );
1009 assert!(
1010 !ath.has_mfa_enabled(user_id).await.unwrap(),
1011 "MFA must not be enabled after wrong code"
1012 );
1013 }
1014
1015 #[tokio::test]
1016 async fn post_mfa_confirm_valid_code_enables_mfa_and_renders_recovery_codes() {
1017 let ath = setup().await;
1018 let app = test_app(ath.clone());
1019 let (user_id, cookie) = create_session(&ath).await;
1020
1021 let csrf = get_csrf(&app, &cookie).await;
1022
1023 let secret = ath.create_mfa_secret(user_id).await.unwrap();
1025 let totp = TOTP::new(
1026 Algorithm::SHA1,
1027 6,
1028 1,
1029 30,
1030 Secret::Encoded(secret).to_bytes().unwrap(),
1031 None,
1032 String::new(),
1033 )
1034 .unwrap();
1035 let code = totp.generate_current().unwrap();
1036
1037 let body_str = format!("code={code}&csrf_token={csrf}");
1038 let req = Request::builder()
1039 .method("POST")
1040 .uri("/settings/mfa/confirm")
1041 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
1042 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1043 .body(Body::from(body_str))
1044 .unwrap();
1045 let resp = app.oneshot(req).await.unwrap();
1046
1047 assert_eq!(resp.status(), StatusCode::OK);
1048 let html = String::from_utf8(
1049 axum::body::to_bytes(resp.into_body(), usize::MAX)
1050 .await
1051 .unwrap()
1052 .to_vec(),
1053 )
1054 .unwrap();
1055 assert!(
1056 html.contains("recovery-code"),
1057 "success must render recovery codes"
1058 );
1059 assert!(
1060 ath.has_mfa_enabled(user_id).await.unwrap(),
1061 "MFA must be enabled after valid confirm"
1062 );
1063 }
1064
1065 #[tokio::test]
1070 async fn post_mfa_disable_removes_mfa_and_redirects() {
1071 let ath = setup().await;
1072 let app = test_app(ath.clone());
1073 let (user_id, cookie) = create_session(&ath).await;
1074 enable_mfa_for_user(&ath, user_id).await;
1075
1076 let session_token_val = cookie.split('=').nth(1).unwrap().to_string();
1078 let session_token = allowthem_core::types::SessionToken::from_encoded(session_token_val);
1079 let csrf =
1080 allowthem_core::derive_csrf_token(&session_token, b"test-csrf-key-for-binary-tests!!");
1081
1082 let body_str = format!("csrf_token={csrf}");
1083 let req = Request::builder()
1084 .method("POST")
1085 .uri("/settings/mfa/disable")
1086 .header(header::COOKIE, &cookie)
1087 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1088 .body(Body::from(body_str))
1089 .unwrap();
1090 let resp = app.oneshot(req).await.unwrap();
1091
1092 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1093 assert_eq!(resp.headers().get("location").unwrap(), "/settings");
1094 assert!(
1095 !ath.has_mfa_enabled(user_id).await.unwrap(),
1096 "MFA must be disabled after disable POST"
1097 );
1098 }
1099
1100 #[tokio::test]
1105 async fn post_regenerate_recovery_codes_renders_new_codes() {
1106 let ath = setup().await;
1107 let app = test_app(ath.clone());
1108 let (user_id, cookie) = create_session(&ath).await;
1109 let (_, old_codes) = enable_mfa_for_user(&ath, user_id).await;
1110
1111 let session_token_val = cookie.split('=').nth(1).unwrap().to_string();
1112 let session_token = allowthem_core::types::SessionToken::from_encoded(session_token_val);
1113 let csrf =
1114 allowthem_core::derive_csrf_token(&session_token, b"test-csrf-key-for-binary-tests!!");
1115
1116 let body_str = format!("csrf_token={csrf}");
1117 let req = Request::builder()
1118 .method("POST")
1119 .uri("/settings/mfa/recovery-codes/regenerate")
1120 .header(header::COOKIE, &cookie)
1121 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1122 .body(Body::from(body_str))
1123 .unwrap();
1124 let resp = app.oneshot(req).await.unwrap();
1125
1126 assert_eq!(resp.status(), StatusCode::OK);
1127 let html = String::from_utf8(
1128 axum::body::to_bytes(resp.into_body(), usize::MAX)
1129 .await
1130 .unwrap()
1131 .to_vec(),
1132 )
1133 .unwrap();
1134 assert!(
1135 html.contains("recovery-code"),
1136 "regeneration must render recovery codes"
1137 );
1138 for old_code in &old_codes {
1140 let valid = ath.verify_recovery_code(user_id, old_code).await.unwrap();
1141 assert!(
1142 !valid,
1143 "old recovery code must be invalidated after regeneration"
1144 );
1145 }
1146 }
1147
1148 #[tokio::test]
1149 async fn post_regenerate_recovery_codes_without_mfa_redirects() {
1150 let ath = setup().await;
1151 let app = test_app(ath.clone());
1152 let (_, cookie) = create_session(&ath).await;
1153
1154 let session_token_val = cookie.split('=').nth(1).unwrap().to_string();
1155 let session_token = allowthem_core::types::SessionToken::from_encoded(session_token_val);
1156 let csrf =
1157 allowthem_core::derive_csrf_token(&session_token, b"test-csrf-key-for-binary-tests!!");
1158
1159 let body_str = format!("csrf_token={csrf}");
1160 let req = Request::builder()
1161 .method("POST")
1162 .uri("/settings/mfa/recovery-codes/regenerate")
1163 .header(header::COOKIE, &cookie)
1164 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1165 .body(Body::from(body_str))
1166 .unwrap();
1167 let resp = app.oneshot(req).await.unwrap();
1168
1169 assert_eq!(
1170 resp.status(),
1171 StatusCode::SEE_OTHER,
1172 "must redirect when MFA is not enabled"
1173 );
1174 assert_eq!(resp.headers().get("location").unwrap(), "/settings");
1175 }
1176
1177 #[tokio::test]
1182 async fn get_mfa_challenge_with_invalid_token_redirects_to_login() {
1183 let ath = setup().await;
1184 let app = test_app(ath);
1185
1186 let req = Request::builder()
1187 .uri("/mfa/challenge?token=not-a-real-token")
1188 .body(Body::empty())
1189 .unwrap();
1190 let resp = app.oneshot(req).await.unwrap();
1191
1192 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1193 assert_eq!(resp.headers().get("location").unwrap(), "/login");
1194 }
1195
1196 #[tokio::test]
1197 async fn get_mfa_challenge_with_valid_token_renders_form() {
1198 let ath = setup().await;
1199 let app = test_app(ath.clone());
1200 let (user_id, _) = create_session(&ath).await;
1201 enable_mfa_for_user(&ath, user_id).await;
1202
1203 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
1204 let req = Request::builder()
1205 .uri(format!("/mfa/challenge?token={token}"))
1206 .body(Body::empty())
1207 .unwrap();
1208 let resp = app.oneshot(req).await.unwrap();
1209
1210 assert_eq!(resp.status(), StatusCode::OK);
1211 let html = String::from_utf8(
1212 axum::body::to_bytes(resp.into_body(), usize::MAX)
1213 .await
1214 .unwrap()
1215 .to_vec(),
1216 )
1217 .unwrap();
1218 assert!(
1219 html.contains("name=\"code\""),
1220 "challenge form must have code input"
1221 );
1222 assert!(
1223 html.contains("mfa_token"),
1224 "challenge form must embed mfa_token hidden field"
1225 );
1226 }
1227
1228 #[tokio::test]
1229 async fn get_mfa_challenge_hx_request_returns_fragment() {
1230 let ath = setup().await;
1231 let app = test_app(ath.clone());
1232 let (user_id, _) = create_session(&ath).await;
1233 enable_mfa_for_user(&ath, user_id).await;
1234
1235 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
1236 let req = Request::builder()
1237 .uri(format!("/mfa/challenge?token={token}"))
1238 .header("HX-Request", "true")
1239 .body(Body::empty())
1240 .unwrap();
1241 let resp = app.oneshot(req).await.unwrap();
1242
1243 assert_eq!(resp.status(), StatusCode::OK);
1244 let html = String::from_utf8(
1245 axum::body::to_bytes(resp.into_body(), usize::MAX)
1246 .await
1247 .unwrap()
1248 .to_vec(),
1249 )
1250 .unwrap();
1251 assert!(
1252 html.contains("<main class=\"wf-auth-form\">"),
1253 "HX response must be a fragment starting at <main>"
1254 );
1255 assert!(
1256 !html.contains("<html"),
1257 "HX response must not render the full shell"
1258 );
1259 }
1260
1261 #[test]
1262 fn render_mfa_setup_fragment_composes_main_and_oob_head() {
1263 let templates = crate::browser_templates::build_default_browser_env();
1264 let config = MfaPageConfig {
1265 templates,
1266 is_production: false,
1267 base_url: "http://127.0.0.1:3100".into(),
1268 };
1269 let totp =
1270 "otpauth://totp/allowthem:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=allowthem";
1271 let html = render_mfa_setup_fragment(
1272 &config,
1273 "csrf-tok",
1274 totp,
1275 &qr_data_uri(totp),
1276 "JBSWY3DPEHPK3PXP",
1277 "",
1278 None,
1279 )
1280 .unwrap()
1281 .0;
1282 assert!(
1283 html.contains("<main class=\"wf-auth-form\">"),
1284 "fragment must include the <main> root"
1285 );
1286 assert!(
1287 html.contains("<title hx-swap-oob=\"true\">"),
1288 "fragment must include the OOB <title> tag"
1289 );
1290 assert!(
1291 html.contains("id=\"wf-screen-label\""),
1292 "fragment must include the OOB #wf-screen-label span"
1293 );
1294 assert!(
1295 html.contains("ENABLE 2FA"),
1296 "fragment must include the ENABLE 2FA status hint"
1297 );
1298 assert!(
1299 html.contains("JBSWY3DPEHPK3PXP"),
1300 "fragment must include the base32 secret"
1301 );
1302 }
1303
1304 #[tokio::test]
1305 async fn get_mfa_setup_hx_request_returns_fragment() {
1306 let ath = setup().await;
1307 let app = test_app(ath.clone());
1308 let (_, cookie) = create_session(&ath).await;
1309 let csrf = get_csrf(&app, &cookie).await;
1310
1311 let req = Request::builder()
1312 .uri("/settings/mfa/setup")
1313 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
1314 .header("HX-Request", "true")
1315 .body(Body::empty())
1316 .unwrap();
1317 let resp = app.oneshot(req).await.unwrap();
1318
1319 assert_eq!(resp.status(), StatusCode::OK);
1320 let html = String::from_utf8(
1321 axum::body::to_bytes(resp.into_body(), usize::MAX)
1322 .await
1323 .unwrap()
1324 .to_vec(),
1325 )
1326 .unwrap();
1327 assert!(
1328 html.contains("<main class=\"wf-auth-form\">"),
1329 "HX response must be a fragment starting at <main>"
1330 );
1331 assert!(
1332 !html.contains("<html"),
1333 "HX response must not render the full shell"
1334 );
1335 }
1336
1337 #[test]
1338 fn render_mfa_recovery_fragment_composes_main_and_oob_head() {
1339 let templates = crate::browser_templates::build_default_browser_env();
1340 let config = MfaPageConfig {
1341 templates,
1342 is_production: false,
1343 base_url: "http://127.0.0.1:3100".into(),
1344 };
1345 let codes = vec!["AAAA-BBBB".to_string(), "CCCC-DDDD".to_string()];
1346 let html = render_mfa_recovery_fragment(&config, &codes, None)
1347 .unwrap()
1348 .0;
1349 assert!(
1350 html.contains("<main class=\"wf-auth-form\">"),
1351 "fragment must include the <main> root"
1352 );
1353 assert!(
1354 html.contains("<title hx-swap-oob=\"true\">"),
1355 "fragment must include the OOB <title> tag"
1356 );
1357 assert!(
1358 html.contains("id=\"wf-screen-label\""),
1359 "fragment must include the OOB #wf-screen-label span"
1360 );
1361 assert!(
1362 html.contains("RECOVERY CODES"),
1363 "fragment must include the RECOVERY CODES status hint"
1364 );
1365 assert!(
1366 html.contains("AAAA-BBBB"),
1367 "fragment must include the rendered recovery codes"
1368 );
1369 assert!(
1370 html.contains("wf-grid"),
1371 "fragment must include the recovery code grid"
1372 );
1373 }
1374
1375 #[tokio::test]
1376 async fn post_mfa_confirm_hx_request_returns_recovery_fragment() {
1377 let ath = setup().await;
1378 let app = test_app(ath.clone());
1379 let (user_id, cookie) = create_session(&ath).await;
1380 let csrf = get_csrf(&app, &cookie).await;
1381
1382 let secret = ath.create_mfa_secret(user_id).await.unwrap();
1383 let totp = TOTP::new(
1384 Algorithm::SHA1,
1385 6,
1386 1,
1387 30,
1388 Secret::Encoded(secret).to_bytes().unwrap(),
1389 None,
1390 String::new(),
1391 )
1392 .unwrap();
1393 let code = totp.generate_current().unwrap();
1394
1395 let body_str = format!("code={code}&csrf_token={csrf}");
1396 let req = Request::builder()
1397 .method("POST")
1398 .uri("/settings/mfa/confirm")
1399 .header(header::COOKIE, format!("{cookie}; csrf_token={csrf}"))
1400 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1401 .header("HX-Request", "true")
1402 .body(Body::from(body_str))
1403 .unwrap();
1404 let resp = app.oneshot(req).await.unwrap();
1405
1406 assert_eq!(resp.status(), StatusCode::OK);
1407 let html = String::from_utf8(
1408 axum::body::to_bytes(resp.into_body(), usize::MAX)
1409 .await
1410 .unwrap()
1411 .to_vec(),
1412 )
1413 .unwrap();
1414 assert!(
1415 html.contains("<main class=\"wf-auth-form\">"),
1416 "HX response must be a fragment starting at <main>"
1417 );
1418 assert!(
1419 !html.contains("<html"),
1420 "HX response must not render the full shell"
1421 );
1422 assert!(
1423 html.contains("recovery-code"),
1424 "HX response must render the recovery codes"
1425 );
1426 }
1427
1428 #[test]
1429 fn render_mfa_challenge_fragment_composes_main_and_oob_head() {
1430 let templates = crate::browser_templates::build_default_browser_env();
1431 let config = MfaPageConfig {
1432 templates,
1433 is_production: false,
1434 base_url: String::new(),
1435 };
1436 let html = render_mfa_challenge_fragment(&config, "mfa-token-abc", "", None)
1437 .unwrap()
1438 .0;
1439 assert!(
1440 html.contains("<main class=\"wf-auth-form\">"),
1441 "fragment must include the <main> root"
1442 );
1443 assert!(
1444 html.contains("<title hx-swap-oob=\"true\">"),
1445 "fragment must include the OOB <title> tag"
1446 );
1447 assert!(
1448 html.contains("id=\"wf-screen-label\""),
1449 "fragment must include the OOB #wf-screen-label span"
1450 );
1451 assert!(
1452 html.contains("TWO-FACTOR"),
1453 "fragment must include the TWO-FACTOR status hint"
1454 );
1455 }
1456
1457 #[tokio::test]
1462 async fn post_mfa_challenge_invalid_token_redirects_to_login() {
1463 let ath = setup().await;
1464 let app = test_app(ath);
1465
1466 let body_str = "mfa_token=garbage&code=123456";
1467 let req = Request::builder()
1468 .method("POST")
1469 .uri("/mfa/challenge")
1470 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1471 .body(Body::from(body_str))
1472 .unwrap();
1473 let resp = app.oneshot(req).await.unwrap();
1474
1475 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1476 assert_eq!(resp.headers().get("location").unwrap(), "/login");
1477 }
1478
1479 #[tokio::test]
1480 async fn post_mfa_challenge_wrong_totp_does_not_consume_challenge() {
1481 let ath = setup().await;
1483 let app = test_app(ath.clone());
1484 let (user_id, _) = create_session(&ath).await;
1485 enable_mfa_for_user(&ath, user_id).await;
1486
1487 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
1488
1489 let body_str = format!("mfa_token={token}&code=000000");
1490 let req = Request::builder()
1491 .method("POST")
1492 .uri("/mfa/challenge")
1493 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1494 .body(Body::from(body_str))
1495 .unwrap();
1496 let resp = app.oneshot(req).await.unwrap();
1497
1498 assert_eq!(resp.status(), StatusCode::OK);
1499 let html = String::from_utf8(
1500 axum::body::to_bytes(resp.into_body(), usize::MAX)
1501 .await
1502 .unwrap()
1503 .to_vec(),
1504 )
1505 .unwrap();
1506 assert!(
1507 html.contains(CHALLENGE_INVALID_TOTP),
1508 "wrong code must show TOTP error"
1509 );
1510
1511 let still_valid = ath.db().validate_mfa_challenge(&token).await.unwrap();
1513 assert!(
1514 still_valid.is_some(),
1515 "challenge must survive a failed attempt"
1516 );
1517 }
1518
1519 #[tokio::test]
1520 async fn post_mfa_challenge_valid_totp_creates_session_and_emits_login() {
1521 let ath = setup().await;
1522 let app = test_app(ath.clone());
1523 let (user_id, _) = create_session(&ath).await;
1524 let (totp, _) = enable_mfa_for_user(&ath, user_id).await;
1525
1526 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
1527 let code = totp.generate_current().unwrap();
1528
1529 let body_str = format!("mfa_token={token}&code={code}");
1530 let req = Request::builder()
1531 .method("POST")
1532 .uri("/mfa/challenge")
1533 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1534 .body(Body::from(body_str))
1535 .unwrap();
1536 let resp = app.oneshot(req).await.unwrap();
1537
1538 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
1539 assert_eq!(resp.headers().get("location").unwrap(), "/");
1540 assert!(
1541 resp.headers().get(header::SET_COOKIE).is_some(),
1542 "session cookie must be set on success"
1543 );
1544
1545 let consumed = ath.db().validate_mfa_challenge(&token).await.unwrap();
1547 assert!(
1548 consumed.is_none(),
1549 "challenge must be consumed after success"
1550 );
1551
1552 let entries = ath.db().get_audit_log(Some(&user_id), 50, 0).await.unwrap();
1554 let event_types: Vec<&allowthem_core::AuditEvent> =
1555 entries.iter().map(|e| &e.event_type).collect();
1556 assert!(
1557 event_types.contains(&&allowthem_core::AuditEvent::MfaChallengeSuccess),
1558 "MfaChallengeSuccess must be in audit log"
1559 );
1560 assert!(
1561 event_types.contains(&&allowthem_core::AuditEvent::Login),
1562 "Login must be in audit log after MFA challenge success"
1563 );
1564 }
1565
1566 #[tokio::test]
1567 async fn post_mfa_challenge_wrong_recovery_code_shows_error() {
1568 let ath = setup().await;
1569 let app = test_app(ath.clone());
1570 let (user_id, _) = create_session(&ath).await;
1571 enable_mfa_for_user(&ath, user_id).await;
1572
1573 let token = ath.db().create_mfa_challenge(user_id).await.unwrap();
1574
1575 let body_str = format!("mfa_token={token}&recovery_code=AAAAAAAA&use_recovery=on");
1576 let req = Request::builder()
1577 .method("POST")
1578 .uri("/mfa/challenge")
1579 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1580 .body(Body::from(body_str))
1581 .unwrap();
1582 let resp = app.oneshot(req).await.unwrap();
1583
1584 assert_eq!(resp.status(), StatusCode::OK);
1585 let html = String::from_utf8(
1586 axum::body::to_bytes(resp.into_body(), usize::MAX)
1587 .await
1588 .unwrap()
1589 .to_vec(),
1590 )
1591 .unwrap();
1592 assert!(
1593 html.contains(CHALLENGE_INVALID_RECOVERY),
1594 "wrong recovery code must show recovery error"
1595 );
1596 }
1597}