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:
@@ -1517,5 +1517,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-02] Accounted Connect direction (founder decision after a fork was resold): the ledger stays AGPL with no licence change and no ee/ split; provider integration logic moves behind the connector (hosted app/api/connect/* today, a separate Connect service later) one upstream at a time on the existing connector keys, ledger and entitlement sync. Rejected: FSL/BSL relicensing (58 public forks keep the AGPL version; DCO-only contributions cannot be relicensed without consent) and closed first-party extensions alone (the code still ships to every self-hoster, and the provider extensions do not honour the Extension API boundary).
|
||||
[2026-09-02] Peppol connector proxy (#2177) is operation-shaped (lookup/submit/status/evidence/recipient/inbound), NOT a path passthrough like the bank proxy: Qvalia URLs embed Arcim's partner and account numbers, the account is shared by every hosted company and every instance so reads must be scoped to what the caller owns, and Qvalia's inbound "read" endpoint marks documents read for the whole account. Ownership is bound to (key, company_ref); participants a key may publish are recorded on the key at issuance (connector_keys.peppol_participants) because the hosted side cannot otherwise know which organisations an instance legitimately hosts. Inbound is served from the hosted archive, never by calling Qvalia on the instance's behalf.
|
||||
[2026-09-02] The connector wire contract is an MIT package (packages/connect-contract, @accounted/connect-contract) consumed in-repo from source through a tsconfig/vitest alias (#2179), and check:guards ratchets the set of files naming a provider API host (#2178): the open repo keeps the contract and the manual file paths, either side of the connection can be implemented outside it, and the grandfathered provider-host set may only shrink. Declined a NOT VALID + later VALIDATE pair for the ledger service CHECK: connector_connections has zero prod rows until keys are issued.
|
||||
[2026-09-02] Stock GoTrue email-change redirects detected via a flow=email_change marker on emailRedirectTo rather than by sniffing ?message= / ?code= on every callback: GoTrue's PKCE redirect carries no type, and the same marker rides along on hook-built token_hash links, so one flag covers both link styles without touching signup/recovery/OAuth paths.
|
||||
[2026-09-02] Sign-off refusals: registered the ReconciliationSignoffError codes in structured-errors with a new thrown_message_sv flag instead of returning err.message from the routes: check:guards forbids raw caught-error messages in user-visible sinks, and the registry keeps the codes discoverable for agents while the dialog still gets the runtime text (dates, amounts).
|
||||
[2026-09-02] Nyckeltal "Resultat per månad" shows the exact per-month figures as an always-on list under the bars (#2198), not behind an "Anpassa" toggle: a preference would touch the type, the PUT schema, the strict preferences-body validator, the dialog and its tests for a switch nobody turns off. Per-bar compact labels are conditional on a glyph-width fit rule and fall back to the single latest label, so they never overlap. Left alone: the monthly path counts only posted entries while the year-total path also counts reversed originals (pinned as intended in tests/pg/kpi-report-aggregates-rpc.pg.test.ts), so a same-year storno makes the sum of months differ from Nettoresultat; visible as numbers now, founder call whether to align the two.
|
||||
[2026-09-03] Email-change double-submit gate is a dedicated per-user claim table + SECURITY DEFINER RPC (migration 20260903083000), not idempotency_keys and not an advisory lock: idempotency_keys requires a company_id the account-level route does not have, and a transaction-scoped advisory lock cannot cover the GoTrue call that happens outside the transaction.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -135,6 +135,39 @@ async function reconcilePendingBankIdIdentity(
|
||||
}
|
||||
}
|
||||
|
||||
type EmailChangeStatus = 'partial' | 'done' | 'failed'
|
||||
|
||||
/**
|
||||
* Status of a stock (GoTrue-hosted) email-change link that came back through
|
||||
* redirect_to. GoTrue puts the outcome in the query for PKCE links: a
|
||||
* half-completed secure change carries ?message=, a dead link ?error= /
|
||||
* ?error_code=, and the completing click ?code=. Implicit-flow links put the
|
||||
* same outcome in the URL fragment, which never reaches the server; the
|
||||
* fallback reads the user's pending state instead of guessing.
|
||||
*/
|
||||
async function resolveStockEmailChangeStatus(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
searchParams: URLSearchParams,
|
||||
code: string | null,
|
||||
): Promise<EmailChangeStatus> {
|
||||
if (searchParams.get('error') || searchParams.get('error_code')) return 'failed'
|
||||
if (searchParams.get('message')) return 'partial'
|
||||
if (code) {
|
||||
// GoTrue mints the code only after the completing verify has flipped the
|
||||
// address, so the change is done whatever happens to the exchange. It
|
||||
// fails when the link is opened in a browser without the PKCE verifier
|
||||
// cookie (a phone mail app); the status page then just has no session
|
||||
// to land, which must not be reported as a failed change.
|
||||
await supabase.auth.exchangeCodeForSession(code)
|
||||
return 'done'
|
||||
}
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) return 'failed'
|
||||
return user.new_email ? 'partial' : 'done'
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams, origin } = new URL(request.url)
|
||||
const code = searchParams.get('code')
|
||||
@@ -169,6 +202,26 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
)
|
||||
|
||||
// Stock GoTrue email-change links (no Send Email hook) verify on the GoTrue
|
||||
// host and come back here through redirect_to instead of carrying a
|
||||
// token_hash: with secure email change the first of the two confirmations
|
||||
// arrives as ?message=..., a dead link as ?error=..., and the completing
|
||||
// click as ?code= (PKCE). /api/account/email stamps flow=email_change on
|
||||
// emailRedirectTo so all three land on the status page like the token_hash
|
||||
// branch below. Before this they fell through to the login bounce with no
|
||||
// message, which reads as "det funkar inte" and invites a retry that voids
|
||||
// the mails just sent.
|
||||
if (searchParams.get('flow') === 'email_change' && !token_hash) {
|
||||
const status = await resolveStockEmailChangeStatus(supabase, searchParams, code)
|
||||
const response = NextResponse.redirect(
|
||||
new URL(`/auth/email-change?status=${status}`, origin),
|
||||
)
|
||||
for (const { name, value, options } of pendingCookies) {
|
||||
response.cookies.set({ name, value, ...options })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
let authenticated = false
|
||||
|
||||
// Handle PKCE flow (code exchange)
|
||||
|
||||
@@ -12,14 +12,41 @@ import { POST } from '../route'
|
||||
function mockUserClient(opts: {
|
||||
user: { id: string; email?: string } | null
|
||||
updateUserError?: { message: string; status?: number; code?: string } | null
|
||||
// What GoTrue returns for the fresh user (the pending-change fields the
|
||||
// claims fast path lacks). Defaults to "no pending change".
|
||||
freshUser?: {
|
||||
new_email?: string
|
||||
email_change_sent_at?: string
|
||||
} | null
|
||||
// Outcome of claim_email_change_request: true (won, default), false
|
||||
// (another request holds the claim), or an error object (RPC failed).
|
||||
claim?: boolean | { message: string; code?: string }
|
||||
}) {
|
||||
const updateUser = vi.fn().mockResolvedValue({
|
||||
data: {},
|
||||
error: opts.updateUserError ?? null,
|
||||
})
|
||||
const rpc = vi.fn().mockImplementation(async (name: string) => {
|
||||
if (name === 'claim_email_change_request') {
|
||||
const claim = opts.claim ?? true
|
||||
return typeof claim === 'boolean'
|
||||
? { data: claim, error: null }
|
||||
: { data: null, error: claim }
|
||||
}
|
||||
return { data: null, error: null }
|
||||
})
|
||||
const getUser = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
user:
|
||||
opts.freshUser === null
|
||||
? null
|
||||
: { id: opts.user?.id, email: opts.user?.email, ...(opts.freshUser ?? {}) },
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = { auth: { updateUser } } as any
|
||||
const supabase = { auth: { updateUser, getUser }, rpc } as any
|
||||
|
||||
if (opts.user) {
|
||||
requireAuthMock.mockResolvedValue({ user: opts.user, supabase, error: null })
|
||||
@@ -31,7 +58,7 @@ function mockUserClient(opts: {
|
||||
})
|
||||
}
|
||||
|
||||
return { updateUser }
|
||||
return { updateUser, getUser, rpc }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -101,7 +128,11 @@ describe('POST /api/account/email', () => {
|
||||
expect(updateUser).toHaveBeenCalledTimes(1)
|
||||
const [attrs, options] = updateUser.mock.calls[0]
|
||||
expect(attrs).toEqual({ email: 'new@testbrand.example' })
|
||||
expect(String(options.emailRedirectTo)).toMatch(/\/auth\/callback$/)
|
||||
// flow=email_change routes the stock GoTrue redirect (message/error/code)
|
||||
// to the email-change status page in /auth/callback.
|
||||
expect(String(options.emailRedirectTo)).toMatch(
|
||||
/\/auth\/callback\?flow=email_change$/,
|
||||
)
|
||||
})
|
||||
|
||||
it('short-circuits a repeat request while the pending mails are fresh', async () => {
|
||||
@@ -168,6 +199,181 @@ describe('POST /api/account/email', () => {
|
||||
expect(updateUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads pending state from GoTrue when the session claims lack it (fresh: no-op)', async () => {
|
||||
// The claims fast path carries no new_email; before this the route
|
||||
// re-issued tokens on every re-submit and voided the mails just sent.
|
||||
const { updateUser, getUser } = mockUserClient({
|
||||
user: { id: 'user-1', email: 'old@testbrand.example' },
|
||||
freshUser: {
|
||||
new_email: 'pending@testbrand.example',
|
||||
email_change_sent_at: new Date(Date.now() - 3 * 60_000).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/account/email', {
|
||||
method: 'POST',
|
||||
body: { email: 'pending@testbrand.example' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data?: { ok: boolean; pending_email: string; resent: boolean }
|
||||
}>(await POST(req))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data?.resent).toBe(false)
|
||||
expect(getUser).toHaveBeenCalledTimes(1)
|
||||
expect(updateUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads pending state from GoTrue when the session claims lack it (stale: re-send)', async () => {
|
||||
const { updateUser } = mockUserClient({
|
||||
user: { id: 'user-1', email: 'old@testbrand.example' },
|
||||
freshUser: {
|
||||
new_email: 'pending@testbrand.example',
|
||||
email_change_sent_at: new Date(
|
||||
Date.now() - 2 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/account/email', {
|
||||
method: 'POST',
|
||||
body: { email: 'pending@testbrand.example' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data?: { resent: boolean }
|
||||
}>(await POST(req))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data?.resent).toBe(true)
|
||||
expect(updateUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('requests a different address even while another change is pending and fresh', async () => {
|
||||
const { updateUser } = mockUserClient({
|
||||
user: { id: 'user-1', email: 'old@testbrand.example' },
|
||||
freshUser: {
|
||||
new_email: 'pending@testbrand.example',
|
||||
email_change_sent_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/account/email', {
|
||||
method: 'POST',
|
||||
body: { email: 'other@testbrand.example' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(updateUser).toHaveBeenCalledTimes(1)
|
||||
expect(updateUser.mock.calls[0][0]).toEqual({ email: 'other@testbrand.example' })
|
||||
})
|
||||
|
||||
it('does not consult GoTrue when the claims already carry the pending change', async () => {
|
||||
const { updateUser, getUser } = mockUserClient({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'old@testbrand.example',
|
||||
new_email: 'pending@testbrand.example',
|
||||
email_change_sent_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
} as { id: string; email?: string },
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/account/email', {
|
||||
method: 'POST',
|
||||
body: { email: 'pending@testbrand.example' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(getUser).not.toHaveBeenCalled()
|
||||
expect(updateUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims the address atomically before calling GoTrue', async () => {
|
||||
const { updateUser, rpc } = mockUserClient({
|
||||
user: { id: 'user-1', email: 'old@testbrand.example' },
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/account/email', {
|
||||
method: 'POST',
|
||||
body: { email: 'new@testbrand.example' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(rpc).toHaveBeenCalledWith('claim_email_change_request', {
|
||||
p_email: 'new@testbrand.example',
|
||||
p_window_seconds: 30 * 60,
|
||||
})
|
||||
// Claim strictly before the GoTrue call.
|
||||
expect(rpc.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateUser.mock.invocationCallOrder[0],
|
||||
)
|
||||
expect(rpc).not.toHaveBeenCalledWith('release_email_change_request')
|
||||
})
|
||||
|
||||
it('answers already-pending without calling GoTrue when a concurrent request holds the claim', async () => {
|
||||
// Both requests read "nothing pending" from GoTrue; only the claim
|
||||
// winner may re-issue the tokens.
|
||||
const { updateUser } = mockUserClient({
|
||||
user: { id: 'user-1', email: 'old@testbrand.example' },
|
||||
claim: false,
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/account/email', {
|
||||
method: 'POST',
|
||||
body: { email: 'new@testbrand.example' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data?: { ok: boolean; pending_email: string; resent: boolean }
|
||||
}>(await POST(req))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({
|
||||
ok: true,
|
||||
pending_email: 'new@testbrand.example',
|
||||
resent: false,
|
||||
})
|
||||
expect(updateUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds without the claim when the RPC itself fails', async () => {
|
||||
const { updateUser, rpc } = mockUserClient({
|
||||
user: { id: 'user-1', email: 'old@testbrand.example' },
|
||||
claim: { message: 'function does not exist', code: '42883' },
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/account/email', {
|
||||
method: 'POST',
|
||||
body: { email: 'new@testbrand.example' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(updateUser).toHaveBeenCalledTimes(1)
|
||||
expect(rpc).not.toHaveBeenCalledWith('release_email_change_request')
|
||||
})
|
||||
|
||||
it('releases the claim when GoTrue refuses the change', async () => {
|
||||
const { rpc } = mockUserClient({
|
||||
user: { id: 'user-1', email: 'old@testbrand.example' },
|
||||
updateUserError: {
|
||||
message: 'AAL2 session is required',
|
||||
status: 403,
|
||||
code: 'insufficient_aal',
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('/api/account/email', {
|
||||
method: 'POST',
|
||||
body: { email: 'new@testbrand.example' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(rpc).toHaveBeenCalledWith('release_email_change_request')
|
||||
})
|
||||
|
||||
it('returns 409 when the address already belongs to another account', async () => {
|
||||
mockUserClient({
|
||||
user: { id: 'user-1', email: 'old@testbrand.example' },
|
||||
|
||||
@@ -59,13 +59,25 @@ export async function POST(request: Request) {
|
||||
// rate limit against double-clicks). Once they are older than that, the
|
||||
// confirmation links may have expired and the user's only recovery path is
|
||||
// re-running the change, so fall through to GoTrue, which restarts the
|
||||
// change and re-sends both mails. new_email/email_change_sent_at are absent
|
||||
// on the claims-mapped fast path; then GoTrue's own rate limit is the
|
||||
// backstop.
|
||||
if (user.new_email && email === user.new_email.toLowerCase()) {
|
||||
const sentAt = user.email_change_sent_at
|
||||
? Date.parse(user.email_change_sent_at)
|
||||
: Number.NaN
|
||||
// change and re-sends both mails.
|
||||
//
|
||||
// new_email/email_change_sent_at live on the GoTrue user, not in the JWT,
|
||||
// so they are absent on the claims-mapped fast path of requireAuth. Reading
|
||||
// them from the claims alone made every re-submit look like a brand-new
|
||||
// request: GoTrue re-issued both tokens and voided the links the user was
|
||||
// about to click, which is exactly the "link invalid" loop users hit after
|
||||
// pressing the button twice. Fetch the fresh user when the claims carry no
|
||||
// pending state; the extra round trip is fine on a route this rare.
|
||||
let pendingEmail = user.new_email
|
||||
let pendingSentAt = user.email_change_sent_at
|
||||
if (!pendingEmail) {
|
||||
const { data } = await supabase.auth.getUser()
|
||||
pendingEmail = data?.user?.new_email
|
||||
pendingSentAt = data?.user?.email_change_sent_at
|
||||
}
|
||||
|
||||
if (pendingEmail && email === pendingEmail.toLowerCase()) {
|
||||
const sentAt = pendingSentAt ? Date.parse(pendingSentAt) : Number.NaN
|
||||
const fresh =
|
||||
Number.isFinite(sentAt) && Date.now() - sentAt < FRESH_PENDING_MS
|
||||
if (fresh) {
|
||||
@@ -79,10 +91,40 @@ export async function POST(request: Request) {
|
||||
// can be an internal origin (dead confirmation links on self-hosted), and
|
||||
// auth links may never follow an attacker-chosen host. Registered
|
||||
// white-label hosts pass through so the mail carries the right brand.
|
||||
//
|
||||
// flow=email_change marks the callback so the stock GoTrue links (verified
|
||||
// on the GoTrue host, returned here via redirect_to with ?message=, ?error=
|
||||
// or ?code= instead of a token_hash) land on the email-change status page
|
||||
// rather than the silent login bounce. The Send Email hook preserves this
|
||||
// query on its token_hash links, so both link styles share the marker.
|
||||
const origin = resolveRequestAppOrigin(request)
|
||||
|
||||
// Cross-instance gate (migration 20260903083000). The pending-state read
|
||||
// above is not atomic: two concurrent requests (two tabs, a retried fetch)
|
||||
// can both see nothing pending, and each updateUser re-issues the tokens
|
||||
// and voids the other's mails. claim_email_change_request is one
|
||||
// INSERT ... ON CONFLICT row lock per user, so exactly one caller per
|
||||
// address per window proceeds; the rest answer "already pending" and send
|
||||
// nothing. A different address always wins the claim. Best effort: if the
|
||||
// RPC itself fails, fall through to GoTrue rather than block the change.
|
||||
const { data: claimed, error: claimError } = await supabase.rpc(
|
||||
'claim_email_change_request',
|
||||
{ p_email: email, p_window_seconds: FRESH_PENDING_MS / 1000 },
|
||||
)
|
||||
if (claimError) {
|
||||
log.warn('email change claim failed; proceeding without it', {
|
||||
userId: user.id,
|
||||
code: claimError.code,
|
||||
})
|
||||
} else if (claimed === false) {
|
||||
return NextResponse.json({
|
||||
data: { ok: true, pending_email: email, resent: false },
|
||||
})
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase.auth.updateUser(
|
||||
{ email },
|
||||
{ emailRedirectTo: `${origin}/auth/callback` },
|
||||
{ emailRedirectTo: `${origin}/auth/callback?flow=email_change` },
|
||||
)
|
||||
|
||||
if (updateError) {
|
||||
@@ -91,6 +133,17 @@ export async function POST(request: Request) {
|
||||
code: updateError.code,
|
||||
status: updateError.status,
|
||||
})
|
||||
// GoTrue sent nothing, so the claim must not block a retry (after MFA,
|
||||
// with another address, once the network is back).
|
||||
if (!claimError) {
|
||||
const { error: releaseError } = await supabase.rpc('release_email_change_request')
|
||||
if (releaseError) {
|
||||
log.warn('email change claim release failed', {
|
||||
userId: user.id,
|
||||
code: releaseError.code,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Addresses are unique per auth user: a change to an already-registered
|
||||
// address is refused by GoTrue, never merged. Accounts are consolidated
|
||||
// via company invitations, not email changes.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Atomic per-user claim for login-email change requests.
|
||||
--
|
||||
-- POST /api/account/email must not re-issue GoTrue's confirmation tokens
|
||||
-- while a change to the same address is still fresh: every re-issue voids
|
||||
-- the links the user is about to click (the "link invalid" loop of
|
||||
-- 2026-09-02). The route reads GoTrue's pending state first, but two
|
||||
-- concurrent requests (a double submit from two tabs, or a retried fetch)
|
||||
-- can both read "nothing pending" and both call updateUser. This table is
|
||||
-- the cross-instance gate: one row per user, claimed with a single
|
||||
-- INSERT ... ON CONFLICT DO UPDATE whose row lock serialises concurrent
|
||||
-- claimers, so exactly one caller proceeds per address per window.
|
||||
--
|
||||
-- Account-level (no company_id): the login e-mail belongs to the auth user.
|
||||
-- Not räkenskapsinformation; classified as infrastructure in
|
||||
-- lib/reports/full-archive-export.ts. Rows go with the user (ON DELETE
|
||||
-- CASCADE) and hold only the address the user typed.
|
||||
|
||||
CREATE TABLE public.email_change_requests (
|
||||
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
target_email text NOT NULL,
|
||||
claimed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.email_change_requests IS
|
||||
'Per-user claim for an in-flight login-email change. Written only through claim_email_change_request / release_email_change_request; gates POST /api/account/email so concurrent requests cannot re-issue confirmation tokens.';
|
||||
|
||||
-- No policies on purpose: nothing reads or writes this table except the two
|
||||
-- SECURITY DEFINER functions below, which key every statement on auth.uid().
|
||||
ALTER TABLE public.email_change_requests ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Returns true when the caller now holds the claim for p_email and may call
|
||||
-- GoTrue; false when another request claimed the same address less than
|
||||
-- p_window_seconds ago (the caller must answer "already pending" and send
|
||||
-- nothing). A different address always wins the claim: the user changed
|
||||
-- their mind, and GoTrue restarts the change for the new address anyway.
|
||||
CREATE OR REPLACE FUNCTION public.claim_email_change_request(
|
||||
p_email text,
|
||||
p_window_seconds integer
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid := auth.uid();
|
||||
v_won boolean;
|
||||
BEGIN
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'claim_email_change_request requires an authenticated user'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
IF p_email IS NULL OR length(btrim(p_email)) = 0 THEN
|
||||
RAISE EXCEPTION 'claim_email_change_request requires an address'
|
||||
USING ERRCODE = '22023';
|
||||
END IF;
|
||||
IF p_window_seconds IS NULL OR p_window_seconds <= 0 THEN
|
||||
RAISE EXCEPTION 'claim_email_change_request requires a positive window'
|
||||
USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.email_change_requests (user_id, target_email, claimed_at)
|
||||
VALUES (v_uid, lower(btrim(p_email)), now())
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET target_email = EXCLUDED.target_email,
|
||||
claimed_at = now()
|
||||
WHERE public.email_change_requests.target_email <> EXCLUDED.target_email
|
||||
OR public.email_change_requests.claimed_at
|
||||
< now() - make_interval(secs => p_window_seconds)
|
||||
RETURNING true INTO v_won;
|
||||
|
||||
RETURN COALESCE(v_won, false);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Drops the caller's claim so a failed GoTrue call (AAL2 refusal, address
|
||||
-- already registered, network) does not lock the user out of retrying for
|
||||
-- the whole window.
|
||||
CREATE OR REPLACE FUNCTION public.release_email_change_request()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'release_email_change_request requires an authenticated user'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
DELETE FROM public.email_change_requests WHERE user_id = auth.uid();
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.claim_email_change_request(text, integer) FROM PUBLIC, anon;
|
||||
REVOKE ALL ON FUNCTION public.release_email_change_request() FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.claim_email_change_request(text, integer) TO authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.release_email_change_request() TO authenticated, service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user