collin/anvil · 8dce3447
Navigate to the identity provider instead of fetching it
Collin Richards · 2026-08-18 14:47 UTC · 8dce3447c75364d87d7ccc67eebd220886ece941 · parent f088bfe3 · browse files
modifiedcrates/anvil-web/src/auth.rs+2 −2
| ⋯ 267 unchanged lines | |||
| 268 | 268 | } | |
| 269 | 269 | (crate::oidc::sign_in_button(&app.config.oidc, next)) | |
| 270 | 270 | form method="post" action="/-/login" style="max-width:320px" { | |
| 271 | - | p { label { "Username" br; input name="username" autofocus; } } | |
| 272 | - | p { label { "Password" br; input name="password" type="password"; } } | |
| 271 | + | p { label { "Username" br; input name="username" autocomplete="username" autofocus; } } | |
| 272 | + | p { label { "Password" br; input name="password" type="password" autocomplete="current-password"; } } | |
| 273 | 273 | button type="submit" { "Sign in" } | |
| 274 | 274 | } | |
| 275 | 275 | }, | |
| ⋯ 32 unchanged lines | |||
modifiedcrates/anvil-web/src/oidc.rs+11 −1
| ⋯ 709 unchanged lines | |||
| 710 | 710 | }; | |
| 711 | 711 | html! { | |
| 712 | 712 | div style="max-width:320px" { | |
| 713 | - | a.btn href=(href) style="display:block;text-align:center" { | |
| 713 | + | // `hx-boost="false"`, because the layout boosts every link and this | |
| 714 | + | // one must be a *browser* navigation. Boosted, htmx fetches | |
| 715 | + | // `/-/oidc/login` by XHR, follows the redirect to the provider as | |
| 716 | + | // an XHR too, and the provider — rightly — sends no CORS header, so | |
| 717 | + | // the sign-in dies in the console instead of opening. A hand-off to | |
| 718 | + | // another origin is a navigation, never a fetch. | |
| 719 | + | a.btn href=(href) hx-boost="false" style="display:block;text-align:center" { | |
| 714 | 720 | "Sign in with " (cfg.label()) | |
| 715 | 721 | } | |
| 716 | 722 | p.muted style="margin:16px 0 4px;font-size:12px" { "or use an anvil password" } | |
| ⋯ 106 unchanged lines | |||
| 823 | 829 | }; | |
| 824 | 830 | let markup = sign_in_button(&on, Some("/collin/anvil?tab=ci")).into_string(); | |
| 825 | 831 | assert!(markup.contains("Sign in with login.localhost"), "{markup}"); | |
| 832 | + | // Without this the layout's `hx-boost` fetches the hand-off by XHR and | |
| 833 | + | // it dies on the provider's CORS policy — a failure nothing but a | |
| 834 | + | // browser console reveals. | |
| 835 | + | assert!(markup.contains(r#"hx-boost="false""#), "{markup}"); | |
| 826 | 836 | assert!( | |
| 827 | 837 | markup.contains("/-/oidc/login?next=/collin/anvil%3Ftab%3Dci"), | |
| 828 | 838 | "{markup}" | |
| ⋯ 6 unchanged lines | |||
modifiedcrates/anvil-web/src/ui.rs+7 −1
| ⋯ 477 unchanged lines | |||
| 478 | 478 | div.nav-dropdown { | |
| 479 | 479 | a href="/-/settings" { "Settings" } | |
| 480 | 480 | @if u.is_admin { a href="/-/admin/usage" { "Disk usage" } } | |
| 481 | - | form method="post" action="/-/logout" { | |
| 481 | + | // Unboosted for the same reason as the | |
| 482 | + | // SSO sign-in button: signing out of a | |
| 483 | + | // provider-linked account redirects to | |
| 484 | + | // the provider, and a boosted form | |
| 485 | + | // would follow that by XHR into a CORS | |
| 486 | + | // wall instead of navigating there. | |
| 487 | + | form method="post" action="/-/logout" hx-boost="false" { | |
| 482 | 488 | button type="submit" { "Sign out" } | |
| 483 | 489 | } | |
| 484 | 490 | } | |
| ⋯ 2347 unchanged lines | |||
modifiedcrates/anvil-web/tests/oidc_flow.rs+36 −0
| ⋯ 270 unchanged lines | |||
| 271 | 271 | } | |
| 272 | 272 | ||
| 273 | 273 | impl Harness { | |
| 274 | + | /// The rendered body of a page, for asserting on markup. | |
| 275 | + | async fn get_body(&self, path: &str, cookie: Option<&str>) -> String { | |
| 276 | + | let mut req = Request::get(path); | |
| 277 | + | if let Some(cookie) = cookie { | |
| 278 | + | req = req.header(header::COOKIE, cookie); | |
| 279 | + | } | |
| 280 | + | let response = self | |
| 281 | + | .router | |
| 282 | + | .clone() | |
| 283 | + | .oneshot(req.body(Body::empty()).unwrap()) | |
| 284 | + | .await | |
| 285 | + | .unwrap(); | |
| 286 | + | let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) | |
| 287 | + | .await | |
| 288 | + | .unwrap(); | |
| 289 | + | String::from_utf8_lossy(&bytes).into_owned() | |
| 290 | + | } | |
| 291 | + | ||
| 274 | 292 | async fn get(&self, path: &str, cookie: Option<&str>) -> (StatusCode, HashMap<String, String>) { | |
| 275 | 293 | let mut req = Request::get(path); | |
| 276 | 294 | if let Some(cookie) = cookie { | |
| ⋯ 140 unchanged lines | |||
| 417 | 435 | .unwrap() | |
| 418 | 436 | .unwrap(); | |
| 419 | 437 | assert_eq!(again.id, user.id); | |
| 438 | + | ||
| 439 | + | // Signing out of a linked account redirects to the provider, so the form | |
| 440 | + | // must escape the layout's `hx-boost` — a boosted POST would follow that | |
| 441 | + | // cross-origin redirect by XHR and be refused by CORS, leaving a button | |
| 442 | + | // that quietly does nothing. | |
| 443 | + | let session = session.split(';').next().unwrap(); | |
| 444 | + | let body = h.get_body("/", Some(session)).await; | |
| 445 | + | let logout = body | |
| 446 | + | .split_once(r#"action="/-/logout""#) | |
| 447 | + | .map(|(before, after)| { | |
| 448 | + | format!( | |
| 449 | + | "{}{}", | |
| 450 | + | &before[before.len().saturating_sub(120)..], | |
| 451 | + | &after[..60.min(after.len())] | |
| 452 | + | ) | |
| 453 | + | }) | |
| 454 | + | .expect("the nav offers a sign-out"); | |
| 455 | + | assert!(logout.contains(r#"hx-boost="false""#), "{logout}"); | |
| 420 | 456 | } | |
| 421 | 457 | ||
| 422 | 458 | /// An account that predates single sign-on is adopted on a *verified* address, | |
| ⋯ 208 unchanged lines | |||