fix(auth): land stock email-change links on the status page and stop retries voiding pending mails (#2199)

* fix(auth): land stock email-change links on the status page and stop retries voiding pending mails

A secure email change needs one click in each mailbox. Stock GoTrue links
verify on the GoTrue host and return to /auth/callback through redirect_to
with ?message= (first click), ?error= (dead link) or ?code= (completing
click); none carries a token_hash, so the callback bounced every one of
them to /login with no message. Users read that as a failure and pressed
"Byt" again, and because the claims fast path carries no new_email, the
route re-issued both tokens on every press and voided the links they were
about to click.

- /api/account/email stamps flow=email_change on emailRedirectTo and reads
  pending state from GoTrue when the session claims lack it, so a repeat
  request inside the 30-minute window is a no-op instead of a re-send.
- /auth/callback routes flow=email_change redirects to
  /auth/email-change?status=partial|done|failed; hook-style token_hash
  links keep using the existing verifyOtp branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMFybWJqw8vScQiEDwKXGi

* fix(auth): let signed-in stock email-change redirects through the proxy and treat a minted code as done

Skeptic findings on e5639fb43:

- The proxy bounced authenticated /auth/callback requests to / unless they
  carried type=email_change. Stock GoTrue links return with only the
  flow=email_change marker, so the new status branch was unreachable from
  the signed-in browser the change usually starts in. Exempt the marker too.
- A completing click opened in a browser without the PKCE verifier (phone
  mail app) failed the code exchange and, with no session to inspect, was
  reported as a failed change although GoTrue had already flipped the
  address. A code is only minted after that verify, so report done.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMFybWJqw8vScQiEDwKXGi

* fix(auth): gate email-change requests with an atomic per-user claim

CodeRabbit on PR #2199: the pending-state read from GoTrue is not atomic,
so two concurrent POST /api/account/email calls (two tabs, a retried
fetch) could both see nothing pending and both re-issue the confirmation
tokens, voiding each other's mails.

Migration 20260903083000 adds email_change_requests (one row per auth
user, RLS with no policies) and two SECURITY DEFINER RPCs:
claim_email_change_request(p_email, p_window_seconds) is a single
INSERT ... ON CONFLICT DO UPDATE whose row lock serialises concurrent
claimers, so exactly one caller per address per window wins; a different
address always wins; release_email_change_request drops the claim when
GoTrue refuses the change so the user can retry.

The route claims right before updateUser, answers resent:false when the
claim is held, releases on GoTrue failure, and falls through to GoTrue if
the RPC itself errors. pg-real test covers sequential, windowed,
concurrent, per-user, release and RLS behaviour. Applied to staging with
the same version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LMFybWJqw8vScQiEDwKXGi

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-03 10:55:12 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 51b68afc87
commit 828628d882
10 changed files with 772 additions and 12 deletions
+17
View File
@@ -640,6 +640,23 @@ describe('updateSession redirect destinations', () => {
expect(locationOf(response)).toBeNull()
})
// Stock GoTrue links (no Send Email hook) come back through redirect_to
// with only the flow=email_change marker: ?message= after the first of
// the two confirmations, ?code= after the completing one, ?error= for a
// dead link. None carries type=, and the change usually starts in a
// signed-in settings tab.
it('lets an authenticated stock email-change redirect reach the callback', async () => {
for (const query of [
'flow=email_change&message=Confirmation+link+accepted',
'flow=email_change&code=pkce',
'flow=email_change&error=access_denied&error_code=otp_expired',
]) {
const response = await run(`/auth/callback?${query}`)
expect(response.status).not.toBe(307)
expect(locationOf(response)).toBeNull()
}
})
it('lets an authenticated user see the email-change status page', async () => {
const response = await run('/auth/email-change?status=done')
expect(response.status).not.toBe(307)
+6 -1
View File
@@ -325,10 +325,15 @@ async function updateSessionInner(
// requests off these paths (a) dropped the confirmation click before
// verifyOtp could consume the token, so the change never completed, and
// (b) hid the /auth/email-change status page in exactly the success case.
// Hook-built links carry type=email_change; stock GoTrue links verify on
// the GoTrue host and return through redirect_to with only the
// flow=email_change marker that /api/account/email stamps on it, so both
// shapes must pass.
if (
pathname.startsWith('/auth/email-change') ||
(pathname.startsWith('/auth/callback') &&
request.nextUrl.searchParams.get('type') === 'email_change')
(request.nextUrl.searchParams.get('type') === 'email_change' ||
request.nextUrl.searchParams.get('flow') === 'email_change'))
) {
return supabaseResponse
}