Skip to content
This repository was archived by the owner on Sep 20, 2026. It is now read-only.

Verify email addresses before they reach the suite - #4

Merged
SourovCodes merged 1 commit into
mainfrom
feature/verify-email-addresses
Sep 20, 2026
Merged

SourovCodes merged 1 commit into
mainfrom
feature/verify-email-addresses

Conversation

@SourovCodes

Copy link
Copy Markdown
Member

The problem

Accounts didn't skip verification so much as assert it falsely.

  • CreateNewUser ran $user->forceFill(['email_verified_at' => now()])->save() on every registration.
  • IdentityEntity publishes 'email_verified' => $this->user->hasVerifiedEmail() into every id_token and /oauth/userinfo response — which, given the line above, was true by construction.
  • Features::emailVerification() was commented out, so verification.* routes didn't exist. resources/views/auth/verify-email.blade.php and the Fortify::verifyEmailView() registration were already written and simply dead.
  • UpdateUserProfileInformation had an instanceof MustVerifyEmail branch to re-verify on email change. User didn't implement the contract, so that branch never ran and emails changed silently.

This mattered because the suite apps match on the address. Invitations are found by pendingFor($user->email) and accepted on a lowercased string comparison, and the SSO callback adopts a pre-existing local account by email. An unproven address was enough to claim either.

What changed

  • Features::emailVerification() enabled; User implements MustVerifyEmail so the framework's checks actually fire.
  • CreateNewUser no longer stamps the column — registration fires Registered, which sends the link.
  • /home and /oauth/switch-account now require verified.
  • New EnsureEmailIsVerifiedForOAuth on the Passport route group.

Existing users are grandfathered. All 6 keep the email_verified_at they already have. No migration, nobody locked out, no surprise re-verification mail.

The part that needed care

Registration signs the new user in before they open the link, so the authorization screen is the door that actually had to be shut — otherwise they walk straight out with tokens.

The gate reads a user rather than demanding one, because the Passport group also carries /oauth/token and /oauth/userinfo, which the client calls with no session.

And Passport applies config('passport.middleware') as group middleware, which places it ahead of the web group — and so ahead of the session the gate reads. Left that way it resolves a null user and passes everyone through while looking perfectly correct. bootstrap/app.php names it in the priority list after StartSession.

Confirmed against a real server (cold request, unverified user):

/oauth/authorize
with the priority entry 302 → /email/verify
without it 200 (authorization screen)

A verified user still gets the continue-as screen (200).

Testing

28 passed, up from 16. Pint clean.

One honest limitation, documented in the test file: the two request-level gate tests cannot catch the ordering bug. Laravel's test client keeps one session store alive across requests within a test, so it's already started by the time the gate runs and the user resolves either way. The the gate is ordered after the session it reads test is the guard that does catch it — verified by removing the priority entry and watching it fail.

Note for the follow-up

With this gate in place, an unverified address can no longer obtain tokens at all, so the client apps are protected without changing them. Making the clients also honour the email_verified claim is still worth doing as defence in depth, and pairs naturally with removing their local email-edit field.

🤖 Generated with Claude Code

Accounts did not skip verification so much as assert it falsely.
CreateNewUser stamped email_verified_at on every registration, and
IdentityEntity published `email_verified` from that column -- so the claim
in every id_token and userinfo response was true by construction. The
verify-email view and Fortify::verifyEmailView() were already written; only
the feature flag was commented out, so the route they served did not exist.

That mattered because the apps downstream match on the address. Invitations
are found by `pendingFor($user->email)` and accepted on a lowercased string
comparison, and the SSO callback adopts a pre-existing local account by
email. An address nobody had proved they held was enough to claim either.

Turn the feature on, implement MustVerifyEmail so the framework's checks
actually fire, and stop stamping the column at registration. Existing users
keep the verified_at they already have: they are accounts we created or
know, and re-verifying them would lock them out to prove a point.

Registration signs the new user in before they have opened the link, so the
authorization screen is the door that had to be shut -- otherwise they walk
straight out with tokens. EnsureEmailIsVerifiedForOAuth sits on the Passport
route group and redirects them to the notice instead. It reads a user rather
than demanding one, because that group also carries /oauth/token and
/oauth/userinfo, which the client calls with no session to read.

Passport applies its configured middleware as *group* middleware, which puts
it ahead of the `web` group and so ahead of the session the gate reads. Left
that way the gate resolves a null user and passes everyone through while
looking correct, so bootstrap/app.php names it in the priority list after
StartSession. Confirmed against a real server: unverified user, cold request,
302 to /email/verify with that entry and 200 without it.

The request tests cannot see that difference -- Laravel's test client keeps
one session store alive across the requests in a test, so the store is
already started by the time the gate runs and the user resolves either way.
The ordering test is the guard that does catch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SourovCodes
SourovCodes merged commit 28473f0 into main Sep 20, 2026
1 check passed
@SourovCodes
SourovCodes deleted the feature/verify-email-addresses branch September 20, 2026 04:45
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant