anvilsign in

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
268268 }
269269 (crate::oidc::sign_in_button(&app.config.oidc, next))
270270 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"; } }
273273 button type="submit" { "Sign in" }
274274 }
275275 },
⋯ 32 unchanged lines
modifiedcrates/anvil-web/src/oidc.rs+11 −1
⋯ 709 unchanged lines
710710 };
711711 html! {
712712 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" {
714720 "Sign in with " (cfg.label())
715721 }
716722 p.muted style="margin:16px 0 4px;font-size:12px" { "or use an anvil password" }
⋯ 106 unchanged lines
823829 };
824830 let markup = sign_in_button(&on, Some("/collin/anvil?tab=ci")).into_string();
825831 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}");
826836 assert!(
827837 markup.contains("/-/oidc/login?next=/collin/anvil%3Ftab%3Dci"),
828838 "{markup}"
⋯ 6 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+7 −1
⋯ 477 unchanged lines
478478 div.nav-dropdown {
479479 a href="/-/settings" { "Settings" }
480480 @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" {
482488 button type="submit" { "Sign out" }
483489 }
484490 }
⋯ 2347 unchanged lines
modifiedcrates/anvil-web/tests/oidc_flow.rs+36 −0
⋯ 270 unchanged lines
271271 }
272272
273273 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+
274292 async fn get(&self, path: &str, cookie: Option<&str>) -> (StatusCode, HashMap<String, String>) {
275293 let mut req = Request::get(path);
276294 if let Some(cookie) = cookie {
⋯ 140 unchanged lines
417435 .unwrap()
418436 .unwrap();
419437 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}");
420456 }
421457
422458 /// An account that predates single sign-on is adopted on a *verified* address,
⋯ 208 unchanged lines