Skip to content

Fix return_to being dropped across the login flow - #14514

Open
garyhtou wants to merge 1 commit into
mainfrom
login-return-to-edge-cases
Open

Fix return_to being dropped across the login flow#14514
garyhtou wants to merge 1 commit into
mainfrom
login-return-to-edge-cases

Conversation

@garyhtou

@garyhtou garyhtou commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary of the problem

When you click a link while signed out, we send you to /users/auth?return_to=... and are supposed to drop you back on that link once you've authenticated. Users reported landing on the dashboard instead. Auditing every branch of the login flow turned up five ways the destination gets thrown away.

Describe your changes

Roughly in order of how often they'd bite:

  • Passkey sign in from the login page. logins/new has no persisted Login yet, so the Stimulus controller posts the assertion to the collection route. set_login built a brand new Login with empty state there, discarding the return_to sitting in the form. It now seeds the login from the param, and the controller forwards the field.
  • "Sign in another way" reached that same collection route and lost return_to the same way. The link was already passing it; nothing read it.
  • Users missing a phone number. After signing in they're sent to their settings, but the settings form never rendered a return_to field, and users#update only honored one when the user's name had also been blank.
  • Signing out to switch accounts. Open an invite while signed in as the wrong account, hit "Sign out", and the invite was gone. users#logout now returns you to the login page with return_to intact. Same for the account badge, which is the switch-accounts affordance on every code entry page and dropped it in both its signed in and signed out forms.
  • Restarting a login. An expired login, a locked account, a failed passkey, or a rejected email all restarted at a bare /users/auth. A restart_login helper preserves return_to, recovering it from an expired login only when the browser token matches.

Two things hardened along the way:

  • login[return_to] bypassed the host filter in ApplicationController, which only sees the top level param. All return_to handling now goes through one safe_return_to helper: same host, a route that exists, not back into the login flow, and length bounded so it can't overflow the 10KB cap on Login#state.
  • The browser token check was keyed off Rails.env.test?, so no spec could reach it. It's now a config flag. It's compared against true because an unset config.x key reads back as a truthy OrderedOptions, which would have disabled the check everywhere.

Testing

spec/requests/login_return_to_spec.rb walks the real HTTP flows, including a real WebAuthn assertion rather than a stub. Every fix was mutation checked: reverting the production change turns a specific example red. That check is what caught two sign out assertions that were originally masking each other, so either fix could have silently reverted.

Full suite green. Not verified in a browser: the local dev database has an unrelated migration rolled back, so the one browser-only piece (a line of JS forwarding the form's return_to) is covered by asserting the field it reads is rendered.

Signing in was supposed to land you back on the page you originally
clicked, but five branches of the flow threw the destination away:

- Passkey sign in from the login page posts to the collection route,
  where set_login built a fresh Login with empty state.
- "Sign in another way" reached that same route.
- Users missing a phone number were sent to settings, whose form never
  rendered a return_to field.
- Signing out to switch accounts (invite links) dropped it, both on the
  "Sign out" link and on the badge that switches accounts mid-login.
- Restarting a login (expiry, locked account, failed passkey, rejected
  email) went to a bare /users/auth.

All return_to handling now goes through one safe_return_to helper: same
host, a route that exists, not back into the login flow, length bounded.
This also covers login[return_to], which the filter in
ApplicationController never reached since it only sees the top level
param.

The browser token check was keyed off Rails.env.test?, so no spec could
exercise it. It is now a config flag, compared against true because an
unset config.x key reads back as a truthy OrderedOptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@garyhtou
garyhtou force-pushed the login-return-to-edge-cases branch from e1dc48e to fb9aa87 Compare August 6, 2026 08:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant