Skip to content

Login: never send a successful login back to the login page - #113956

Open
leonardost wants to merge 1 commit into
trunkfrom
fix/dotobrd-359-post-login-redirect-loop
Open

Login: never send a successful login back to the login page#113956
leonardost wants to merge 1 commit into
trunkfrom
fix/dotobrd-359-post-login-redirect-loop

Conversation

@leonardost

Copy link
Copy Markdown
Contributor

Related to DOTOBRD-359

Proposed Changes

  • rebootAfterLogin no longer redirects to /log-in. When the sanitized redirect points at the login page, the destination nested in its own redirect_to is used instead; /home is the fallback when there is nothing usable inside.
  • Every other destination passes through byte-for-byte, including the public-api.wordpress.com/oauth2/authorize hand-off, which is deliberately not on the login allowlist.
  • The allowlist moves from client/login/redirect-logged-in/index.web.js to client/lib/login-redirect-safety so the route middleware and the post-login reboot share one policy. Its behaviour is unchanged.
  • calypso_login_success gains a redirect_to_login_page boolean.

Why

A login that has already succeeded should never land on the login page. Passwordless accounts cannot get past that page at all, so for them it is a loop with no way out — the DOTOBRD-359 report, and a desktop-browser repro on CMM-1230.

Two things to be clear about, because the last attempt at this bug foundered on them:

This is a guard, not a root-cause fix. PR #108992 said the magic-login API "returns a sanitized redirect_to that wraps the actual destination in a login page URL". I could not confirm that from the server code. Login_Base_Endpoint::create_response_with_redirect_url() runs wp_sanitize_redirectwp_validate_redirect → the login_redirect filter and echoes the client's own value back; the only login_redirect filter on wpcom rewrites to home_url('/') for private-blog users and nothing else. The client does not nest either — request-login-email-form.jsx and login-form.jsx both pass getRedirectToOriginal( state ) straight through. So whatever puts a login URL into redirect_to happens before the link is minted and has not been identified.

That is why redirect_to_login_page is here. It measures how often the condition actually occurs, which nobody can currently say. The guard is worth having regardless: it holds no matter what the cause turns out to be.

The *.wordpress.com allowlist is untouched, on purpose. #108992 also proposed treating any *.wordpress.com subdomain as internal. That would fix a real second symptom — a magic link clicked while already logged in lands on /home rather than the user's site, because redirectLoggedIn rejects the site URL as external. But wordpress.com subdomains are user-controlled, so widening the allowlist is an open-redirect decision rather than a bug fix, and it should be yours rather than mine. Flagging it here; happy to add it if you want it.

Testing instructions

Unit tests:

yarn test-client client/lib/login-redirect-safety client/state/login/actions/test/reboot-after-login.js

26 tests. The five loop cases in reboot-after-login.js fail against current trunk; the pass-through cases (OAuth2 authorize, Atomic site URLs, ordinary paths) pass both before and after, which is the point of them.

Full login area is green: yarn test-client client/login client/state/login — 212 tests.

Manual:

  • Create a passwordless account, request a magic login link, click it.
  • Verify you land on your destination rather than back on /log-in.
  • Separately confirm the OAuth2 flow is unaffected: log in via an app that sends you through public-api.wordpress.com/oauth2/authorize and check login_flow=true is still appended.

A login that has just succeeded should not land on /log-in. Passwordless
accounts cannot get past that page at all, so when it happens they are
stuck in a loop with no way out.

rebootAfterLogin now unwraps the destination nested in such a redirect and
uses it, falling back to /home when there is nothing usable inside. Every
other destination is passed through exactly as before, including the
OAuth2 authorize hand-off, which is not on the login allowlist.

The allowlist itself moves to client/lib/login-redirect-safety so the
route middleware and the post-login reboot cannot drift apart. Its
behaviour is unchanged.

calypso_login_success gains redirect_to_login_page, which records how
often the redirect points back at the login page. The mechanism that puts
a login URL there has not been identified, so this measures it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PUZ6qRGHHyWu2uva4Nqg2V
@leonardost leonardost self-assigned this Aug 31, 2026
@github-actions

Copy link
Copy Markdown

Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

App Entrypoints (~188 bytes added 📈 [gzipped])

Details
Name Parsed Gzip
entry-login +494 B +0.0% +188 B +0.0%

Common code that is always downloaded and parsed every time the app is loaded, no matter which route is used.

Legend

What is parsed and gzip size?

Parsed Size: Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory.
Gzip Size: Compressed size of the JS and CSS files. This much data needs to be downloaded over network.

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