Skip to content

Native auth 2c-II: registration, verification, reset, change password/email (issue #147) - #169

Merged
JanMikes merged 9 commits into
mainfrom
feature/native-auth-2c-flows-ii
Jul 25, 2026
Merged

Native auth 2c-II: registration, verification, reset, change password/email (issue #147)#169
JanMikes merged 9 commits into
mainfrom
feature/native-auth-2c-flows-ii

Conversation

@JanMikes

@JanMikes JanMikes commented Jul 24, 2026

Copy link
Copy Markdown
Member

Slice 2c-II of the Auth0 → native Symfony auth migration (issue #147), following #165 (2a data model), #166 (2b security plumbing) and #167 (2c-I login page + sign-in link).

Everything ships dark. NATIVE_REGISTRATION_ENABLED and NATIVE_LOGIN_ENABLED stay OFF; nothing user-visible changes on merge.

Done

  • RegistrationRegisterUser handler creates UserAccount (msp|<uuid7>) + Player in one transaction. Rejects an email that already belongs to a Player, not just to a user_account: in window A that collision is the user's own Auth0 account, and letting a second account take the address would make the Stage B import skip their identity and strand their profile and every solving time.
  • Stranded-registrant rescue — the /welcome screen and the verification email both carry the sign-in-link URL, because a window-A registrant who logs out meets the Auth0 form, which has no identity for them and which we cannot brand or link from. Both suppress the warning once login is native, so it retires by itself.
  • Email verificationSendEmailVerificationLink handler + email, anonymous /verify-email (success / expired / invalid / failed, grants no session), rate-limited resend on profile settings.
  • Password reset/password-reset asks, /password-reset/{token} consumes. Unknown, throttled and known addresses are indistinguishable. The token stays in the URL with Referrer-Policy: no-referrer rather than in a session: a session cookie here would follow the visitor across every later page and break the Make anonymous HTML responses shared-cacheable (fixes #139) #164 anonymous-cacheability property.
  • Native change password + change email — both require the current password, so a hijacked session cannot lock the owner out. Imported bcrypt hashes verify through the migrating hasher and land as argon2id. Change-email keeps player.email in step, resets email_verified_at, kills outstanding reset links aimed at the old inbox, and re-sends verification.
  • The Add change password feature via Auth0 password reset email #161 card swap needed no flagedit-profile.html.twig branches on whether the session holds a UserAccount, so window-A registrants get the native cards the day they register and the Auth0 branch dies on its own once Stage B has converted everyone. Removal of the three Add change password feature via Auth0 password reset email #161 classes + AUTH0_DB_CONNECTION stays open for Stage B, as the plan specifies.
  • All copy in all 6 locales (en, cs, de, es, fr, ja), UI + transactional emails. check-translations reports 0 missing keys.

Bugs found and fixed while reviewing this slice

Worth calling out, because each would have shipped dark and only bitten once a flag flipped:

  1. Security::login() without an authenticator name throws. The main firewall carries three authenticators through window A and refuses to guess between them — so every native registration would have 500'd on Stage A day. RegisterControllerTest fails with Too many authenticators were found without the fix.
  2. The "forgot password" CSRF token id was not stateless, so rendering the form for an anonymous visitor started a session and set a cookie — a straight Make anonymous HTML responses shared-cacheable (fixes #139) #164 regression that would have made every later page uncacheable for that visitor.
  3. Changing the account email left outstanding reset links alive. Reset tokens bind to the account, not the address, and losing a mailbox is a common reason to change it — so whoever held the old inbox kept an hour-long way back in.
  4. The change-email ownership check was non-deterministic. player.email is not unique (7 known duplicate pairs); comparing one arbitrary row's user_id meant the same request could be allowed or refused depending on what Postgres returned.
  5. The verification email's "valid for 24 hours" was a separate literal from the token's actual lifetime.
  6. A reset link a mail client wrapped or truncated 404'd instead of reaching the "here is a new one" page.

Plus one test-infrastructure regression of my own: making the flags Twig globals meant they get resolved on nearly every rendered page, which turned LoginControllerTest's pre-existing unset($_ENV[...]) teardown into 355 suite-wide EnvNotFoundException errors. Both flag tests now restore the original value through a shared OverridesFeatureFlagEnv trait.

Is it dark? Yes — verified route by route

With both flags OFF, every route this branch adds redirects to /login (checked against the running container): /register, /password-reset, /password-reset/{token}, /welcome, and the two credential-settings pages. The edit-profile Auth0 card renders byte-identical to main for Auth0 sessions, and the new navbar CTA is flag-gated.

Two things were fixed to make that true rather than nearly-true:

  • /password-reset was live. Because production's user_account table stays empty until the Stage B import, it could only answer "if an account exists, a link is on its way" and then send nothing — a dead end made silent by the very anti-enumeration uniformity the page needs. Both reset routes are now gated on native auth being reachable (registration OR login, so a rollback of one does not take reset down with it).
  • The cutover modal is dropped (D15 amendment, Jan's call): the site-wide notice strip already reaches everyone including anonymous visitors and links to the explainer page, so a modal repeating it on the login page is a second interruption. The reasoning is recorded in the README decision table, the plan and the comms plan.

Deployment note: the two flags are now Twig globals, so they are resolved on nearly every page render instead of only on /login. They are committed in .env (=0) and .dockerignore excludes only .env.*, so the image carries them — but production must keep those vars resolvable or page rendering fails.

Independent review

A separate adversarial review pass over the diff found no authentication/authorization bypass, no window-A collision gap, no #164 regression and no CQRS/project-rule violation. It did find one real gap — the handlers were well covered but the controllers in front of them were not — so this branch now also tests, at the HTTP layer: #[IsGranted] on both credential-settings pages, the current-password gate through the form, the window-A instanceof UserAccount guard (a legacy Auth0 session redirects instead of hitting a TypeError), all four /verify-email outcomes, and the reset round trip end to end including single use.

Known dependency on the next slice

RetrieveLoggedUserProfile still recognises only Auth0\Symfony\Models\User, so a native UserAccount session gets logged_user.profile === null — which means EditProfileController bounces it and the native change-password/change-email cards are unreachable until 2d lands. Nothing user-visible: both flags are OFF and Stage A ships after 2d. Recorded at the top of the 2d checklist so it gets done first.

Gates

phpstan · cs-fix · --testsuite "Project Test Suite" (1386 green) · doctrine:schema:validate · cache:warmup. Panther not run in this slice — it is a 2d/hardening gate.

Stopped exactly at

Slice 2c-II complete; every §2c checkbox in the implementation plan is now [x].

Next

Slice 2d — the code sweep: RetrieveLoggedUserProfile for both user classes (do this first, see above), OAuth2AuthorizationSubscriber, the ~50-file Auth0\Symfony\Models\UserUserAccount #[CurrentUser] sweep, the TestingLogin / TestLoginController / Panther base rewrite, UserAccount fixtures, audit-logging listeners + counters, last_login_at, and the Panther login + OAuth2 authorize round-trip tests.

EditProfileController was already moved off the concrete Auth0 User hint here — it had to be, or a native account visiting profile settings would hit a TypeError. The rest of the sweep is untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc

JanMikes and others added 9 commits July 25, 2026 00:47
…/email

Builds the rest of the user-facing native auth flows on top of the domain
layer from 2a (issue #147). Everything ships dark: registration is gated on
`native_registration`, the login page (and its cutover modal) on
`native_login`, both OFF.

- Registration: RegisterUser handler creates UserAccount (msp|<uuid7>) +
  Player in one transaction. Rejects an email that already belongs to a
  *Player*, not just to a user_account - in window A that collision is the
  user's own Auth0 account, and a second account would make the Stage B
  import skip their identity and strand their profile.
- Welcome screen + verification email both carry the sign-in-link rescue: a
  window-A registrant who logs out meets the Auth0 form, which has no
  identity for them.
- Email verification UI: anonymous /verify-email consumes the stateless
  token, resend button on profile settings (rate limited per account).
- Password reset UI + email: /password-reset asks, /password-reset/{token}
  consumes. Uniform response for unknown/throttled addresses; the token page
  sends Referrer-Policy: no-referrer instead of parking the token in a
  session (keeps anonymous pages cookie-free, #164).
- Native change password + change email, both requiring the current
  password. The edit-profile card follows the account class in the session,
  not a flag: a native account gets the native cards from the day it exists,
  a legacy Auth0 session keeps the #161 button until the Stage B import.
- Cutover explainer modal on the login page (D15), localStorage-dismissed so
  it needs no session.

EN copy only in this commit; the other five locales follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
Flips the 2c registration / verification / reset / change-password /
change-email / cutover-modal checkboxes and records what each item actually
does, including the two places the implementation diverged from the plan:

- the edit-profile card swap needed no flag - it branches on whether the
  session holds a UserAccount, so window-A native registrants get native
  cards immediately and the Auth0 branch retires itself after the Stage B
  import;
- the reset token stays in the URL with Referrer-Policy: no-referrer rather
  than in a session, which would put a cookie on every later page (#164).

Removal of the three #161 classes and AUTH0_DB_CONNECTION stays open for
Stage B, as specified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
87 keys × cs/de/es/fr/ja across messages and emails: registration, the
welcome screen, email verification, password reset, the native
change-password and change-email cards, the cutover explainer modal, and the
two new transactional emails.

D17 requires all six locales for anything auth-facing - Auth0's Universal
Login was auto-localized, so English-only auth pages would be a regression.

check-translations reports 0 missing keys in every locale; placeholders,
inline HTML and block scalars verified byte-identical to the English source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
All four would have shipped dark and only bitten at Stage A/B.

1. Security::login() without an authenticator name throws through window A.
   The `main` firewall carries three authenticators (LoginFormAuthenticator,
   auth0.authenticator, login_link) and Security::login() refuses to guess -
   so EVERY native registration would have 500'd the moment the flag flipped.
   Now names LoginFormAuthenticator explicitly;
   RegisterControllerTest::testRegistrationCreatesAccountAndPlayerAndSignsTheUserIn
   fails with "Too many authenticators were found" without the fix.

2. The "forgot password" CSRF token id was not in stateless_token_ids, so
   rendering the form for an anonymous visitor reached for the session and
   put a cookie on the page - a straight #164 regression that would have made
   every later page uncacheable for that visitor.

3. Changing the account email left outstanding password-reset requests alive.
   Reset tokens bind to the account, not the address, and losing control of a
   mailbox is a common reason to change it - so whoever held the old inbox
   kept an hour-long way back in. Now invalidated with the address.

4. The reset-token route required exactly 64 hex chars, so a link a mail
   client wrapped or truncated 404'd instead of reaching the "this link does
   not work, here is a new one" page.

Adds RegisterControllerTest: both flag states, the window-A player-email
collision, weak-password rejection, no-session/no-shared-cache on the
anonymous GET, and locale negotiation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
24 handler tests across registration, verification-link sending, password
reset mailing, change-password and change-email, plus fixes for three
defects they surfaced.

Fixes:

- ChangeAccountEmailHandler asked "fetch a player on this address and compare
  its user_id to mine". player.email is not unique - production carries 7
  known duplicate pairs - and the lookup is an unordered LIMIT 1, so when the
  target address sat on both a stale row and the caller's own, the change was
  allowed or refused depending on which row Postgres returned. Replaced with
  PlayerRepository::emailBelongsToAnotherPlayer(), which asks the question
  directly. Covered by
  testAddressSharedWithAStaleDuplicateOfTheCallerIsStillRefused.

- The verification email promised "valid for 24 hours" from a hardcoded
  literal while the token's real lifetime lived in a separate constant.
  Folded into EmailVerificationTokenSigner::LIFETIME_HOURS.

- Two concurrent registrations on one address both pass the advisory checks
  and one loses at the unique index. That is the same situation for the user
  as a plain collision, so it now gets the same message instead of a generic
  failure and a Sentry alert for a benign race.

Test-infrastructure fix, and it is mine: adding native_login_enabled /
native_registration_enabled as Twig globals made them resolve on nearly every
rendered page, which turned LoginControllerTest's existing
`unset($_ENV['NATIVE_LOGIN_ENABLED'])` teardown into 355 suite-wide
EnvNotFoundException errors. Both flag tests now restore the original value
through the OverridesFeatureFlagEnv trait, and the registration tests vary
REMOTE_ADDR so the per-IP limiter's cache (not rolled back between runs)
cannot starve them.

Full suite: 1363 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
…147)

The security-critical property of "forgot password" is that it gives nothing
away: a known address, an unknown address and a throttled repeat all have to
look identical from the outside (D8), even though only one of them sends
mail. That was untested until now.

Also covers the end-to-end token (the mailed link opens the form), the
no-session/no-shared-cache requirement on the anonymous form page (#164), the
mangled-link case that motivated relaxing the route requirement, and the
Referrer-Policy that keeps the token out of outbound referers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
An independent review pass found no security defect in the slice but did find
a coverage gap: the handlers behind change-password, change-email and email
verification were well tested, while the controllers in front of them were
not - so a regression in an #[IsGranted] attribute, a window-A instanceof
guard or the CSRF wiring would have gone unnoticed.

- ChangeAccountCredentialsControllerTest: both pages closed to anonymous
  visitors, the current-password gate enforced through the form (and the
  stored hash / address left untouched when it fails), the new address
  starting unverified with its link going to the NEW inbox, and a legacy
  Auth0 session redirected rather than crashing on the #[CurrentUser] type.
- VerifyEmailControllerTest: valid link confirms and grants no session,
  replay inside the lifetime still reads as success without moving the
  timestamp (D18), forged/missing/expired tokens each land on their own
  message, a link dies when the address it was issued for changes, and the
  page sets no cookie (#164).
- The reset round trip end to end: the mailed link sets a new password, does
  not sign the browser in, and cannot be opened twice.

Full suite: 1385 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
Two changes, both aimed at making this slice genuinely dark until Stage A.

**Cutover modal dropped (D15 amendment, Jan's call 2026-07-25).** The
site-wide notice strip shipped ahead of Stage A already reaches everyone,
anonymous visitors included, and links to the explainer page. A modal
repeating the same message on the login page is a second interruption with
the same content. Removed the template, the Stimulus controller, the include
and the copy in all six locales; the amendment is recorded in the README
decision table, the implementation plan and the communication plan so the
reasoning survives. The login page keeps UX-funnel layers 3, 4 and 6 - the
permanent microcopy, the prominent sign-in-link CTA and the failure helper -
which fire when somebody is actually stuck rather than on arrival.

**Password reset is now gated too.** It was the one page this branch left
live with both flags OFF, and because production's user_account table is
empty until the Stage B import, it could only ever answer "if an account
exists, a link is on its way" and then send nothing - a silent dead end, made
silent precisely by the anti-enumeration uniformity it needs elsewhere. Both
reset routes now redirect to /login until a native account can exist. Gated
on registration OR login rather than on either alone, so a rollback that
leaves login native does not take password reset down with it.

With both flags OFF every route this branch adds now redirects to /login:
/register, /password-reset, /password-reset/{token}, /welcome and the two
credential-settings pages. Verified against the running container.

Full suite: 1386 green; translations complete in all six locales.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
/edit-profile is the one page real users meet that this slice changed, and
nothing rendered it in a test. Two cases now do:

- a legacy Auth0 session still gets the #161 "send password change email"
  form and none of the native cards - the offer ~10k existing users see is
  unchanged, which is the property that actually matters on merge day;
- a native UserAccount session is still bounced to /my-profile.

The second pins the known 2c-II -> 2d dependency instead of the end state:
RetrieveLoggedUserProfile still recognises only the Auth0 user class, so a
native session has a null profile and this controller's guard redirects it,
leaving the native cards unreachable. Harmless while both flags are OFF (no
native account exists in production), and the first task of slice 2d - at
which point this test has to flip, as its docblock says.

Full suite: 1388 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
@JanMikes
JanMikes marked this pull request as ready for review July 25, 2026 09:37
@JanMikes
JanMikes merged commit ac3106d into main Jul 25, 2026
5 checks passed
@JanMikes
JanMikes deleted the feature/native-auth-2c-flows-ii branch July 25, 2026 09:37
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