* 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>
148 lines
5.2 KiB
TypeScript
148 lines
5.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextRequest } from 'next/server'
|
|
|
|
// Stock (GoTrue-hosted) email-change links: the outcome comes back through
|
|
// redirect_to as ?message= / ?error= / ?code= with flow=email_change stamped by
|
|
// /api/account/email, never as a token_hash. The branch returns before any of
|
|
// the invite/team/landing machinery runs; these mocks only keep the module
|
|
// importable in the test environment.
|
|
vi.mock('@/lib/auth/invite-tokens', () => ({ hashInviteToken: vi.fn() }))
|
|
vi.mock('@/lib/auth/consume-invite-cookie', () => ({
|
|
INVITE_COOKIE_NAME: 'gnubok-invite-token',
|
|
}))
|
|
vi.mock('@/lib/company/landing-server', () => ({
|
|
resolveLandingDestination: vi.fn().mockResolvedValue('/'),
|
|
}))
|
|
vi.mock('@/lib/company/pending-invites', () => ({
|
|
acceptPendingTeamInviteByToken: vi.fn(),
|
|
}))
|
|
|
|
const verifyOtp = vi.fn()
|
|
const exchangeCodeForSession = vi.fn()
|
|
const getUser = vi.fn()
|
|
vi.mock('@supabase/ssr', () => ({
|
|
createServerClient: () => ({
|
|
auth: {
|
|
verifyOtp: (...args: unknown[]) => verifyOtp(...args),
|
|
exchangeCodeForSession: (...args: unknown[]) => exchangeCodeForSession(...args),
|
|
getUser: (...args: unknown[]) => getUser(...args),
|
|
},
|
|
}),
|
|
}))
|
|
|
|
import { GET } from '../route'
|
|
|
|
const STATUS_PAGE = 'https://app.testbrand.example/auth/email-change?status='
|
|
|
|
function makeRequest(params: Record<string, string>) {
|
|
const url = new URL('https://app.testbrand.example/auth/callback')
|
|
for (const [key, value] of Object.entries(params)) {
|
|
url.searchParams.set(key, value)
|
|
}
|
|
return new NextRequest(url)
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
getUser.mockResolvedValue({ data: { user: null }, error: null })
|
|
exchangeCodeForSession.mockResolvedValue({ data: {}, error: null })
|
|
})
|
|
|
|
describe('GET /auth/callback flow=email_change (stock GoTrue links)', () => {
|
|
it('redirects the first of the two confirmations (?message=) to the partial page', async () => {
|
|
const res = await GET(
|
|
makeRequest({
|
|
flow: 'email_change',
|
|
message:
|
|
'Confirmation link accepted. Please proceed to confirm link sent to the other email',
|
|
}),
|
|
)
|
|
|
|
expect(res.headers.get('location')).toBe(`${STATUS_PAGE}partial`)
|
|
expect(exchangeCodeForSession).not.toHaveBeenCalled()
|
|
expect(verifyOtp).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('redirects a dead link (?error=) to the failed page', async () => {
|
|
const res = await GET(
|
|
makeRequest({
|
|
flow: 'email_change',
|
|
error: 'access_denied',
|
|
error_code: 'otp_expired',
|
|
error_description: 'Email link is invalid or has expired',
|
|
}),
|
|
)
|
|
|
|
expect(res.headers.get('location')).toBe(`${STATUS_PAGE}failed`)
|
|
})
|
|
|
|
it('exchanges the completing click (?code=) and redirects to the done page', async () => {
|
|
const res = await GET(makeRequest({ flow: 'email_change', code: 'pkce-code' }))
|
|
|
|
expect(exchangeCodeForSession).toHaveBeenCalledWith('pkce-code')
|
|
expect(res.headers.get('location')).toBe(`${STATUS_PAGE}done`)
|
|
})
|
|
|
|
it('reports done when the code exchange fails in a browser without a session', async () => {
|
|
// Completing link opened in a phone mail app: no PKCE verifier cookie,
|
|
// no session cookie. GoTrue already flipped the address before minting
|
|
// the code, so this is a completed change, not a failed one.
|
|
exchangeCodeForSession.mockResolvedValue({
|
|
data: {},
|
|
error: { message: 'PKCE code verifier not found' },
|
|
})
|
|
getUser.mockResolvedValue({ data: { user: null }, error: null })
|
|
|
|
const res = await GET(makeRequest({ flow: 'email_change', code: 'pkce-code' }))
|
|
|
|
expect(exchangeCodeForSession).toHaveBeenCalledWith('pkce-code')
|
|
expect(getUser).not.toHaveBeenCalled()
|
|
expect(res.headers.get('location')).toBe(`${STATUS_PAGE}done`)
|
|
})
|
|
|
|
it('falls back to the pending state when the outcome only came in the fragment', async () => {
|
|
getUser.mockResolvedValue({
|
|
data: {
|
|
user: {
|
|
id: 'u1',
|
|
email: 'old@testbrand.example',
|
|
new_email: 'new@testbrand.example',
|
|
},
|
|
},
|
|
error: null,
|
|
})
|
|
|
|
const res = await GET(makeRequest({ flow: 'email_change' }))
|
|
|
|
expect(res.headers.get('location')).toBe(`${STATUS_PAGE}partial`)
|
|
})
|
|
|
|
it('reports failed when nothing can be read (no outcome, no session)', async () => {
|
|
const res = await GET(makeRequest({ flow: 'email_change' }))
|
|
|
|
expect(res.headers.get('location')).toBe(`${STATUS_PAGE}failed`)
|
|
})
|
|
|
|
it('leaves hook-style token_hash links to the verifyOtp branch', async () => {
|
|
verifyOtp.mockResolvedValue({
|
|
data: { user: null, session: null },
|
|
error: null,
|
|
})
|
|
|
|
const res = await GET(
|
|
makeRequest({ flow: 'email_change', token_hash: 'th', type: 'email_change' }),
|
|
)
|
|
|
|
expect(verifyOtp).toHaveBeenCalledWith({ token_hash: 'th', type: 'email_change' })
|
|
expect(exchangeCodeForSession).not.toHaveBeenCalled()
|
|
expect(res.headers.get('location')).toBe(`${STATUS_PAGE}partial`)
|
|
})
|
|
|
|
it('does not hijack other flows that carry a ?message= or ?code=', async () => {
|
|
const res = await GET(makeRequest({ code: 'pkce-code' }))
|
|
|
|
expect(exchangeCodeForSession).toHaveBeenCalledWith('pkce-code')
|
|
expect(res.headers.get('location') ?? '').not.toContain('/auth/email-change')
|
|
})
|
|
})
|