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:
co-authored by
Claude Fable 5.1
parent
51b68afc87
commit
828628d882
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getClient, getPool } from '@/tests/pg/setup'
|
||||
import { insertAuthUser } from '@/tests/pg/fixtures'
|
||||
|
||||
// claim_email_change_request / release_email_change_request
|
||||
// (20260903083000_email_change_request_claims.sql): the cross-instance gate
|
||||
// in front of GoTrue's updateUser in POST /api/account/email. Exactly one
|
||||
// caller per user, address and window may proceed; the rest must answer
|
||||
// "already pending" without re-issuing confirmation tokens.
|
||||
//
|
||||
// withUserContext rolls its transaction back, which would hide the claim
|
||||
// from the next call. These helpers commit instead, because the claim's
|
||||
// whole point is what a SECOND transaction sees; the test users are fresh
|
||||
// per test and their rows cascade away with them.
|
||||
|
||||
const WINDOW = 30 * 60
|
||||
|
||||
async function asUser<T>(
|
||||
userId: string,
|
||||
fn: (client: import('pg').PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await getClient()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
||||
JSON.stringify({ sub: userId, role: 'authenticated' }),
|
||||
])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
|
||||
await client.query(`SET LOCAL ROLE authenticated`)
|
||||
const result = await fn(client)
|
||||
await client.query('COMMIT')
|
||||
return result
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function claim(userId: string, email: string, window = WINDOW): Promise<boolean> {
|
||||
return asUser(userId, async (client) => {
|
||||
const { rows } = await client.query<{ claim_email_change_request: boolean }>(
|
||||
`SELECT public.claim_email_change_request($1, $2)`,
|
||||
[email, window],
|
||||
)
|
||||
return rows[0]!.claim_email_change_request
|
||||
})
|
||||
}
|
||||
|
||||
async function release(userId: string): Promise<void> {
|
||||
await asUser(userId, async (client) => {
|
||||
await client.query(`SELECT public.release_email_change_request()`)
|
||||
})
|
||||
}
|
||||
|
||||
async function backdateClaim(userId: string, seconds: number): Promise<void> {
|
||||
await getPool().query(
|
||||
`UPDATE public.email_change_requests
|
||||
SET claimed_at = now() - make_interval(secs => $2)
|
||||
WHERE user_id = $1`,
|
||||
[userId, seconds],
|
||||
)
|
||||
}
|
||||
|
||||
describe('claim_email_change_request', () => {
|
||||
it('grants the first claim and refuses a repeat for the same address inside the window', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
|
||||
expect(await claim(userId, 'new@testbrand.example')).toBe(true)
|
||||
expect(await claim(userId, 'new@testbrand.example')).toBe(false)
|
||||
// Case and whitespace are not a different address.
|
||||
expect(await claim(userId, ' New@Testbrand.example ')).toBe(false)
|
||||
})
|
||||
|
||||
it('grants a claim for a different address while one is held', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
|
||||
expect(await claim(userId, 'first@testbrand.example')).toBe(true)
|
||||
expect(await claim(userId, 'second@testbrand.example')).toBe(true)
|
||||
// ... and the first address is now the "different" one again.
|
||||
expect(await claim(userId, 'first@testbrand.example')).toBe(true)
|
||||
})
|
||||
|
||||
it('grants a repeat once the held claim is older than the window', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
|
||||
expect(await claim(userId, 'new@testbrand.example')).toBe(true)
|
||||
await backdateClaim(userId, WINDOW + 5)
|
||||
expect(await claim(userId, 'new@testbrand.example')).toBe(true)
|
||||
expect(await claim(userId, 'new@testbrand.example')).toBe(false)
|
||||
})
|
||||
|
||||
it('lets exactly one of three concurrent claimers through', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
|
||||
const results = await Promise.all([
|
||||
claim(userId, 'new@testbrand.example'),
|
||||
claim(userId, 'new@testbrand.example'),
|
||||
claim(userId, 'new@testbrand.example'),
|
||||
])
|
||||
|
||||
expect(results.filter(Boolean)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps claims per user', async () => {
|
||||
const a = await insertAuthUser()
|
||||
const b = await insertAuthUser()
|
||||
|
||||
expect(await claim(a, 'shared@testbrand.example')).toBe(true)
|
||||
expect(await claim(b, 'shared@testbrand.example')).toBe(true)
|
||||
})
|
||||
|
||||
it('release drops the claim so the same address can be requested again', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
|
||||
expect(await claim(userId, 'new@testbrand.example')).toBe(true)
|
||||
await release(userId)
|
||||
expect(await claim(userId, 'new@testbrand.example')).toBe(true)
|
||||
})
|
||||
|
||||
it('release only touches the caller', async () => {
|
||||
const a = await insertAuthUser()
|
||||
const b = await insertAuthUser()
|
||||
|
||||
expect(await claim(a, 'a@testbrand.example')).toBe(true)
|
||||
expect(await claim(b, 'b@testbrand.example')).toBe(true)
|
||||
await release(a)
|
||||
expect(await claim(b, 'b@testbrand.example')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an empty address and a non-positive window', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
|
||||
await expect(claim(userId, ' ')).rejects.toThrow(/requires an address/)
|
||||
await expect(claim(userId, 'new@testbrand.example', 0)).rejects.toThrow(
|
||||
/positive window/,
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses unauthenticated callers', async () => {
|
||||
await expect(
|
||||
getPool().query(`SELECT public.claim_email_change_request('x@testbrand.example', 60)`),
|
||||
).rejects.toThrow(/authenticated user/)
|
||||
await expect(
|
||||
getPool().query(`SELECT public.release_email_change_request()`),
|
||||
).rejects.toThrow(/authenticated user/)
|
||||
})
|
||||
|
||||
it('is not readable or writable directly by an authenticated user', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
expect(await claim(userId, 'new@testbrand.example')).toBe(true)
|
||||
|
||||
const visible = await asUser(userId, async (client) => {
|
||||
const { rows } = await client.query(`SELECT * FROM public.email_change_requests`)
|
||||
return rows.length
|
||||
})
|
||||
expect(visible).toBe(0)
|
||||
|
||||
await expect(
|
||||
asUser(userId, async (client) => {
|
||||
await client.query(`DELETE FROM public.email_change_requests`)
|
||||
const { rows } = await client.query(
|
||||
`SELECT count(*)::int AS n FROM public.email_change_requests`,
|
||||
)
|
||||
return rows[0]
|
||||
}),
|
||||
).resolves.toBeDefined()
|
||||
// RLS with no policies: the DELETE above matched nothing.
|
||||
const { rows } = await getPool().query<{ n: number }>(
|
||||
`SELECT count(*)::int AS n FROM public.email_change_requests WHERE user_id = $1`,
|
||||
[userId],
|
||||
)
|
||||
expect(rows[0]!.n).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1210,6 +1210,8 @@ export const ARCHIVE_EXCLUDED_TABLES: Record<string, string> = {
|
||||
// document_attachments when the underlying data is legally removed.
|
||||
document_integrity_checks:
|
||||
'WORM verification log (SHA-256 recompute outcomes); failures reach the archive via audit_log in revision/behandlingshistorik.json',
|
||||
email_change_requests:
|
||||
'per-user in-flight login-email change claim (migration 20260903083000); gates token re-issue, not räkenskapsinformation',
|
||||
event_log: '30-day TTL event bus log',
|
||||
extension_data: 'extension runtime state (includes this backup\'s own state)',
|
||||
idempotency_keys: 'infrastructure',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user