Bug/tic unlink (#1153)
* fix(tic): allow BankID link/unlink without a company context /bankid/link and /bankid/unlink are user-level actions, but the extension dispatcher resolved an active company for them, so a zero-company user (fresh BankID signup, pre-onboarding) got a 500 'No company context' when managing the connection from /settings/account. Mark both routes skipCompanyContext and resolve the caller in-handler via requireAuth(), which preserves the dispatcher's MFA/AAL2 enforcement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tic): return 409 account_exists instead of 500 on BankID signup with taken email The signup guard pre-checked profiles.email, but the authoritative store is auth.users: anonymized account tombstones (and any profile drift) hold the email in auth.users while profiles.email is NULL. The guard missed, createUser failed with email_exists (422), and the route surfaced a dead-end 500 'Kunde inte skapa kontot. Forsok igen.' where retrying can never succeed. Drop the profiles pre-check and let createUser's own uniqueness check be the guard: map email_exists to the existing 409 account_exists response (Swedish message), which the register page already handles with a toast and a redirect to login. Also removes the TOCTOU window between the old pre-check and createUser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(account): actually scrub auth.users metadata on account deletion The delete route passed user_metadata: {} / app_metadata: {} to auth.admin.updateUserById assuming replace semantics, but GoTrue MERGES metadata maps, so the wipe was a silent no-op: the ~100-year tombstone kept the user's full name in raw_user_meta_data (verified on production 2026-07-24). Move the scrub into anonymize_user_account (migration 20260724150000): raw_user_meta_data is cleared entirely, raw_app_meta_data drops the app-specific keys (bankid_linked, has_password) while GoTrue's provider/providers stay, and auth.users.email is still retained as the documented legitimate-interest tombstone. The migration also repairs existing tombstones (guarded by profiles.anonymized_at). The route keeps only the ban, which the DB function cannot set. Migration content already applied to staging; pg-real test extended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: log BankID signup guard decision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(account): address PR review findings on anonymize scrub - anonymize_user_account now rejects repeat invocations against an already-anonymized tombstone (SQLSTATE P0002) instead of re-churning the scrubbed row - note that the tombstone repair UPDATE runs atomically inside the migration transaction - tic signup failure log hashes the email (sha256 prefix, matching the pnrHashPrefix pattern) instead of logging the raw address - pg-real test for the double-invocation guard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(anonymization): ensure raw_app_meta_data is not null before scrubbing keys --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
213d611e54
commit
63fd5311ed
@@ -360,5 +360,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-24] Accounted MCP naming is an additive namespace selected with tool_namespace=accounted: internal gnubok_* ids, authorization maps, API-key prefixes, and the gnubok-mcp package remain canonical compatibility surfaces, while new clients advertise accounted_* aliases through accounted-mcp so existing connections never invalidate.
|
||||
[2026-07-24] PR-review pass on bug/invalid-imports: declined CodeRabbit's ask to fill un-posted prior years in asset-note-figures ibAck via the engine when priorPosted.length>0. The note must tie out to the ledger-driven balansrakning, which reflects posted-only accumulated depreciation; estimating a skipped year would over-state ibAck and BREAK the tie-out this module exists to preserve. A posted-history gap is a real books gap that the build-data tie-out warning correctly surfaces (fix = post the missing year, not paper over it). Engine fallback stays gated to priorPosted.length===0 (pre-onboarding, nothing booked).
|
||||
[2026-07-24] Kept the leading-`!` Tailwind important syntax (`!p-0`/`!pl-0`/`!pr-0`) in the transaction tables over CodeRabbit's trailing-`!` (`p-0!`) rewrite: verified against the installed tailwindcss 4.1.18 compiler that `!p-0` alone emits `.\!p-0{padding:… !important}`, so both forms work in v4 and the flush-edge columns are not broken. Declined the 18-site churn.
|
||||
[2026-07-24] BankID signup email guard = createUser email_exists error mapping, not a profiles.email pre-check: profiles is not a reliable mirror of auth.users (anonymize tombstones scrub it), and the error-based check has no TOCTOU window. Deleted-account emails stay blocked for re-signup by design (tombstone keeps auth.users.email).
|
||||
[2026-07-24] Journey onboarding stack MERGED to main (#1141, #1145 ex-#1142, #1143) after founder preview click-through + "safe to merge". Production flag NEXT_PUBLIC_ONBOARDING_JOURNEY deliberately NOT set at merge time: BankID roles-prefill path is reducer-tested but not yet live-verified, so the flip is an explicit founder step, followed by one BankID smoke and then PR D (wizard deletion, /companies/new mode='add', picker restyle).
|
||||
[2026-07-24] Onboarding journey migration COMPLETE with PR #1150: wizard deleted, /companies/new on journey mode='add', BankID picker = searchable list, flag conditional removed (env var cleaned from Vercel post-merge). Bot-review triage: compliance findings on getUser()/redirect()/ensure_user_team skipped as App Router misreadings or pre-existing patterns; fixed the real ones (stale select_company keys, unused hasExistingCompanies plumbing).
|
||||
|
||||
@@ -137,16 +137,17 @@ describe('POST /api/account/delete', () => {
|
||||
})
|
||||
expect(updateUserById).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
user_metadata: {},
|
||||
app_metadata: {},
|
||||
ban_duration: expect.any(String),
|
||||
})
|
||||
expect.objectContaining({ ban_duration: expect.any(String) })
|
||||
)
|
||||
const updatePayload = updateUserById.mock.calls[0][1]
|
||||
// Email must NOT be scrubbed: retaining it is what blocks re-signup
|
||||
// with the same address. Recovery goes through support instead.
|
||||
const updatePayload = updateUserById.mock.calls[0][1]
|
||||
expect(updatePayload).not.toHaveProperty('email')
|
||||
// Metadata is NOT wiped here: GoTrue merges metadata maps, so passing {}
|
||||
// was a no-op. The anonymize_user_account RPC scrubs auth.users directly
|
||||
// (migration 20260724150000).
|
||||
expect(updatePayload).not.toHaveProperty('user_metadata')
|
||||
expect(updatePayload).not.toHaveProperty('app_metadata')
|
||||
expect(adminSignOut).toHaveBeenCalledWith('user-1', 'global')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted[0]).toMatchObject({ userId: 'user-1' })
|
||||
|
||||
@@ -90,8 +90,8 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Wipe PII in auth.users metadata and ban the tombstone row ~100 years.
|
||||
// DB functions can't reach supabase.auth.admin, so we do it here.
|
||||
// Ban the tombstone row ~100 years so login is impossible. The DB function
|
||||
// can't set the ban (GoTrue-managed), so we do it here.
|
||||
//
|
||||
// Note: auth.users.email is intentionally NOT scrubbed. The original
|
||||
// address is retained as a legitimate-interest tombstone so that:
|
||||
@@ -105,18 +105,18 @@ export async function POST(request: Request) {
|
||||
// after this point: login is impossible (row is banned) and the
|
||||
// profile is anonymized, so no UI ever surfaces it.
|
||||
//
|
||||
// user_metadata / app_metadata ARE wiped: they may contain display
|
||||
// name, avatar, or provider info that isn't needed for recovery.
|
||||
// The admin API replaces (not merges) these, so passing {} clears them.
|
||||
// user_metadata / app_metadata PII is scrubbed by the RPC itself, NOT
|
||||
// here: GoTrue's admin update MERGES metadata maps, so the previous
|
||||
// updateUserById(..., { user_metadata: {}, app_metadata: {} }) call was
|
||||
// a silent no-op that left the full name on the tombstone (found on
|
||||
// prod 2026-07-24, repaired by migration 20260724150000).
|
||||
const service = createServiceClient()
|
||||
try {
|
||||
await service.auth.admin.updateUserById(user.id, {
|
||||
user_metadata: {},
|
||||
app_metadata: {},
|
||||
ban_duration: '876000h',
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('Failed to wipe metadata and ban anonymized user', { userId: user.id, err })
|
||||
log.error('Failed to ban anonymized user', { userId: user.id, err })
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -103,11 +103,17 @@ afterEach(() => {
|
||||
describe('POST /bankid/complete', () => {
|
||||
describe('signup mode: account_exists regression (CWE-287)', () => {
|
||||
it('returns 409 account_exists and performs NO side effects when email is already registered', async () => {
|
||||
// The guard is createUser's own auth.users uniqueness check, NOT a
|
||||
// profiles.email pre-check: anonymized tombstones (account deletion)
|
||||
// have no profiles.email but still hold the address in auth.users.
|
||||
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
|
||||
const { admin, client } = mockServiceClient([
|
||||
{ data: null }, // bankid_identities pnr lookup → not linked
|
||||
{ data: { id: 'victim-user-uuid' } }, // profiles email lookup → EXISTS
|
||||
])
|
||||
admin.createUser.mockResolvedValueOnce({
|
||||
data: { user: null },
|
||||
error: { status: 422, code: 'email_exists', message: 'A user with this email address has already been registered' },
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
|
||||
method: 'POST',
|
||||
@@ -121,15 +127,37 @@ describe('POST /bankid/complete', () => {
|
||||
expect(body.error).toBe('account_exists')
|
||||
expect(body.data).toBeUndefined()
|
||||
|
||||
// Critical: none of the account-mutation or session-issuance calls ran.
|
||||
expect(admin.createUser).not.toHaveBeenCalled()
|
||||
// Critical: no account mutation or session issuance happened.
|
||||
expect(admin.updateUserById).not.toHaveBeenCalled()
|
||||
expect(admin.generateLink).not.toHaveBeenCalled()
|
||||
expect(admin.deleteUser).not.toHaveBeenCalled()
|
||||
|
||||
// No insert into bankid_identities. Only two from() calls should have happened
|
||||
// (the pnr lookup and the profile lookup), neither of which is an insert.
|
||||
// No insert into bankid_identities: the only from() call is the pnr lookup.
|
||||
const fromCalls = vi.mocked(client.from).mock.calls
|
||||
expect(fromCalls.map((c) => c[0])).toEqual(['bankid_identities', 'profiles'])
|
||||
expect(fromCalls.map((c) => c[0])).toEqual(['bankid_identities'])
|
||||
})
|
||||
|
||||
it('returns 500 internal_error for createUser failures that are NOT email_exists', async () => {
|
||||
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
|
||||
const { admin } = mockServiceClient([
|
||||
{ data: null }, // pnr lookup → not linked
|
||||
])
|
||||
admin.createUser.mockResolvedValueOnce({
|
||||
data: { user: null },
|
||||
error: { status: 500, code: 'unexpected_failure', message: 'boom' },
|
||||
} as never)
|
||||
|
||||
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
|
||||
method: 'POST',
|
||||
body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error?: string }>(
|
||||
await findCompleteHandler()(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('internal_error')
|
||||
expect(admin.generateLink).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,7 +166,6 @@ describe('POST /bankid/complete', () => {
|
||||
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
|
||||
const { admin } = mockServiceClient([
|
||||
{ data: null }, // pnr lookup → not linked
|
||||
{ data: null }, // email lookup → not taken
|
||||
{ error: null }, // bankid_identities insert OK
|
||||
])
|
||||
|
||||
@@ -200,7 +227,6 @@ describe('POST /bankid/complete', () => {
|
||||
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
|
||||
const { admin } = mockServiceClient([
|
||||
{ data: null }, // pnr lookup → not linked
|
||||
{ data: null }, // email lookup → not taken
|
||||
{ error: { message: 'insert boom', code: 'XX000' } }, // identity insert FAILS
|
||||
])
|
||||
|
||||
@@ -223,7 +249,6 @@ describe('POST /bankid/complete', () => {
|
||||
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
|
||||
const { admin } = mockServiceClient([
|
||||
{ data: null }, // pnr lookup → not linked
|
||||
{ data: null }, // email lookup → not taken
|
||||
{ error: null }, // identity insert OK
|
||||
])
|
||||
admin.generateLink.mockResolvedValueOnce({
|
||||
@@ -248,7 +273,6 @@ describe('POST /bankid/complete', () => {
|
||||
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
|
||||
const { admin } = mockServiceClient([
|
||||
{ data: null }, // pnr lookup → not linked
|
||||
{ data: null }, // email lookup → not taken
|
||||
])
|
||||
admin.updateUserById.mockResolvedValueOnce({
|
||||
data: null,
|
||||
@@ -272,7 +296,6 @@ describe('POST /bankid/complete', () => {
|
||||
it('does NOT delete anything on the happy path', async () => {
|
||||
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
|
||||
const { admin } = mockServiceClient([
|
||||
{ data: null },
|
||||
{ data: null },
|
||||
{ error: null },
|
||||
])
|
||||
@@ -354,7 +377,6 @@ describe('POST /bankid/complete', () => {
|
||||
})
|
||||
const { client } = mockServiceClient([
|
||||
{ data: null }, // pnr lookup → not linked
|
||||
{ data: null }, // email lookup → not taken
|
||||
{ error: null }, // bankid_identities insert OK
|
||||
])
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
vi.mock('../lib/bankid-client', () => ({
|
||||
@@ -15,17 +16,41 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
import { collectBankIdResult } from '../lib/bankid-client'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { ticExtension } from '../index'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
const TEST_KEY = 'a'.repeat(64)
|
||||
|
||||
function findHandler(method: string, path: string) {
|
||||
function findRoute(method: string, path: string) {
|
||||
const route = ticExtension.apiRoutes!.find((r) => r.method === method && r.path === path)
|
||||
if (!route) throw new Error(`${method} ${path} route not found in ticExtension.apiRoutes`)
|
||||
return route.handler
|
||||
return route
|
||||
}
|
||||
|
||||
function findHandler(method: string, path: string) {
|
||||
return findRoute(method, path).handler
|
||||
}
|
||||
|
||||
function mockAuthenticated(userId = 'user-1') {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: { id: userId },
|
||||
supabase: {},
|
||||
error: null,
|
||||
} as unknown as Awaited<ReturnType<typeof requireAuth>>)
|
||||
}
|
||||
|
||||
function mockUnauthenticated() {
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: null,
|
||||
supabase: {},
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
} as unknown as Awaited<ReturnType<typeof requireAuth>>)
|
||||
}
|
||||
|
||||
type QueuedResult = { data?: unknown; error?: unknown }
|
||||
@@ -73,8 +98,6 @@ function mockServiceClient(fromResults: QueuedResult[], appMetadata: Record<stri
|
||||
return { admin, client }
|
||||
}
|
||||
|
||||
const ctx = { userId: 'user-1' } as unknown as ExtensionContext
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('BANKID_ENCRYPTION_KEY', TEST_KEY)
|
||||
@@ -84,18 +107,35 @@ afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('route flags', () => {
|
||||
// These routes are user-level: a zero-company user (fresh BankID signup,
|
||||
// pre-onboarding) must be able to manage the connection from settings.
|
||||
// Without skipCompanyContext the dispatcher throws 'No company context'.
|
||||
it('link and unlink skip company context', () => {
|
||||
expect(findRoute('POST', '/bankid/link').skipCompanyContext).toBe(true)
|
||||
expect(findRoute('POST', '/bankid/unlink').skipCompanyContext).toBe(true)
|
||||
})
|
||||
|
||||
it('link and unlink still require auth', () => {
|
||||
expect(findRoute('POST', '/bankid/link').skipAuth).toBeUndefined()
|
||||
expect(findRoute('POST', '/bankid/unlink').skipAuth).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /bankid/unlink', () => {
|
||||
it('returns 401 without an authenticated context', async () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockUnauthenticated()
|
||||
mockServiceClient([], {})
|
||||
const req = createMockRequest('/api/extensions/ext/tic/bankid/unlink', { method: 'POST' })
|
||||
const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/unlink')(req))
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('merges app_metadata instead of replacing it — has_password must survive unlink', async () => {
|
||||
it('merges app_metadata instead of replacing it: has_password must survive unlink', async () => {
|
||||
// A BankID-only user: has_password false. Wiping it would make
|
||||
// userHasPassword() infer TRUE (bankid_linked false ⇒ password assumed),
|
||||
// hiding the set-password escape hatch from a user with no login method.
|
||||
mockAuthenticated()
|
||||
const { admin } = mockServiceClient(
|
||||
[{ error: null }], // bankid_identities delete OK
|
||||
{ has_password: false, bankid_linked: true, provider: 'email' }
|
||||
@@ -103,7 +143,7 @@ describe('POST /bankid/unlink', () => {
|
||||
|
||||
const req = createMockRequest('/api/extensions/ext/tic/bankid/unlink', { method: 'POST' })
|
||||
const { status, body } = await parseJsonResponse<{ data?: { unlinked?: boolean } }>(
|
||||
await findHandler('POST', '/bankid/unlink')(req, ctx)
|
||||
await findHandler('POST', '/bankid/unlink')(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
@@ -114,6 +154,7 @@ describe('POST /bankid/unlink', () => {
|
||||
})
|
||||
|
||||
it('returns 500 when the identity delete fails and does not touch app_metadata', async () => {
|
||||
mockAuthenticated()
|
||||
const { admin } = mockServiceClient(
|
||||
[{ error: { message: 'delete boom', code: 'XX000' } }],
|
||||
{ has_password: false, bankid_linked: true }
|
||||
@@ -121,7 +162,7 @@ describe('POST /bankid/unlink', () => {
|
||||
|
||||
const req = createMockRequest('/api/extensions/ext/tic/bankid/unlink', { method: 'POST' })
|
||||
const { status } = await parseJsonResponse(
|
||||
await findHandler('POST', '/bankid/unlink')(req, ctx)
|
||||
await findHandler('POST', '/bankid/unlink')(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(500)
|
||||
@@ -143,7 +184,30 @@ describe('POST /bankid/link', () => {
|
||||
} as unknown as Awaited<ReturnType<typeof collectBankIdResult>>
|
||||
}
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockUnauthenticated()
|
||||
mockServiceClient([], {})
|
||||
const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
|
||||
method: 'POST',
|
||||
body: { sessionId: 'test-session' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/link')(req))
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when sessionId is missing', async () => {
|
||||
mockAuthenticated()
|
||||
mockServiceClient([], {})
|
||||
const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/link')(req))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('merges app_metadata so an existing has_password: true survives linking', async () => {
|
||||
mockAuthenticated()
|
||||
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
|
||||
const { admin } = mockServiceClient(
|
||||
[
|
||||
@@ -158,7 +222,7 @@ describe('POST /bankid/link', () => {
|
||||
body: { sessionId: 'test-session' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data?: { linked?: boolean } }>(
|
||||
await findHandler('POST', '/bankid/link')(req, ctx)
|
||||
await findHandler('POST', '/bankid/link')(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
@@ -169,6 +233,7 @@ describe('POST /bankid/link', () => {
|
||||
})
|
||||
|
||||
it('returns 409 already_linked when the personnummer belongs to another user', async () => {
|
||||
mockAuthenticated()
|
||||
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
|
||||
const { admin } = mockServiceClient(
|
||||
[{ data: { user_id: 'someone-else' } }],
|
||||
@@ -180,7 +245,7 @@ describe('POST /bankid/link', () => {
|
||||
body: { sessionId: 'test-session' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ error?: string }>(
|
||||
await findHandler('POST', '/bankid/link')(req, ctx)
|
||||
await findHandler('POST', '/bankid/link')(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(409)
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { TICCompanyProfile, TICFinancialReportSummary } from './lib/tic-typ
|
||||
import type { BankIdCompleteRequest } from './lib/bankid-types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
@@ -960,30 +961,11 @@ export const ticExtension: Extension = {
|
||||
)
|
||||
}
|
||||
|
||||
// If the email is already registered, refuse signup. Linking BankID to an
|
||||
// existing account must go through the authenticated /bankid/link route so
|
||||
// email ownership is proven by password login first. (CWE-287)
|
||||
const { data: existingByEmail } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('email', trimmedEmail!)
|
||||
.single()
|
||||
|
||||
if (existingByEmail) {
|
||||
log.warn('bankid signup rejected: email already registered', {
|
||||
sessionId,
|
||||
pnrHashPrefix: pnrHash.slice(0, 8),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'account_exists',
|
||||
message: 'An account with this email already exists. Log in and link BankID from settings.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create new Supabase user
|
||||
// Create new Supabase user. Email uniqueness is checked by createUser
|
||||
// itself against auth.users: do NOT pre-check profiles.email instead.
|
||||
// The profile mirror can lack the address while the auth row still
|
||||
// holds it (anonymize_user_account scrubs profiles.email but keeps the
|
||||
// auth tombstone), which used to fall through to a dead-end 500 here.
|
||||
const randomPassword = crypto.randomBytes(32).toString('base64url')
|
||||
const { data: newUser, error: createError } = await supabase.auth.admin.createUser({
|
||||
email: trimmedEmail!,
|
||||
@@ -993,7 +975,30 @@ export const ticExtension: Extension = {
|
||||
})
|
||||
|
||||
if (createError || !newUser?.user) {
|
||||
log.error('createUser failed', { email: trimmedEmail, status: createError?.status, code: createError?.code, message: createError?.message })
|
||||
// Email already registered (including deleted-account tombstones,
|
||||
// which keep their email on purpose): refuse signup. Linking BankID
|
||||
// to an existing account must go through the authenticated
|
||||
// /bankid/link route so email ownership is proven by password login
|
||||
// first. (CWE-287)
|
||||
if (createError?.code === 'email_exists') {
|
||||
log.warn('bankid signup rejected: email already registered', {
|
||||
sessionId,
|
||||
pnrHashPrefix: pnrHash.slice(0, 8),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'account_exists',
|
||||
message: 'Det finns redan ett konto med den här e-postadressen. Logga in och koppla BankID under Inställningar.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
log.error('createUser failed', {
|
||||
emailHashPrefix: crypto.createHash('sha256').update(trimmedEmail!).digest('hex').slice(0, 8),
|
||||
status: createError?.status,
|
||||
code: createError?.code,
|
||||
message: createError?.message,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
|
||||
{ status: 500 }
|
||||
@@ -1127,13 +1132,24 @@ export const ticExtension: Extension = {
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/link',
|
||||
// skipAuth: false, requires existing Supabase session
|
||||
handler: async (request: Request, ctx?) => {
|
||||
// Requires a Supabase session but NOT a company: a user who just
|
||||
// signed up (or hasn't finished onboarding) must be able to manage
|
||||
// their BankID connection from /settings/account. Without this flag
|
||||
// the dispatcher's requireCompanyId() throws 'No company context'
|
||||
// for zero-company users. skipCompanyContext dispatches without ctx,
|
||||
// so the handler resolves the caller itself via requireAuth() (same
|
||||
// MFA/AAL2 enforcement the dispatcher applies).
|
||||
skipCompanyContext: true,
|
||||
handler: async (request: Request) => {
|
||||
try {
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) return auth.error
|
||||
const userId = auth.user.id
|
||||
|
||||
const body = await request.json()
|
||||
const { sessionId } = body
|
||||
|
||||
if (!sessionId || !ctx?.userId) {
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -1157,14 +1173,14 @@ export const ticExtension: Extension = {
|
||||
.eq('personal_number_hash', pnrHash)
|
||||
.single()
|
||||
|
||||
if (existing && existing.user_id !== ctx.userId) {
|
||||
if (existing && existing.user_id !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'already_linked', message: 'This BankID is already linked to another account' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existing && existing.user_id === ctx.userId) {
|
||||
if (existing && existing.user_id === userId) {
|
||||
return NextResponse.json({ data: { linked: true, alreadyLinked: true } })
|
||||
}
|
||||
|
||||
@@ -1172,7 +1188,7 @@ export const ticExtension: Extension = {
|
||||
const { error: insertError } = await supabase
|
||||
.from('bankid_identities')
|
||||
.insert({
|
||||
user_id: ctx.userId,
|
||||
user_id: userId,
|
||||
personal_number_hash: pnrHash,
|
||||
personal_number_enc: encryptPersonalNumber(personalNumber),
|
||||
given_name: givenName,
|
||||
@@ -1192,9 +1208,9 @@ export const ticExtension: Extension = {
|
||||
// { bankid_linked: true } would wipe has_password for users who
|
||||
// already set one, they'd then be incorrectly shown the
|
||||
// set-password banner on their next session.
|
||||
const { data: priorUser } = await supabase.auth.admin.getUserById(ctx.userId)
|
||||
const { data: priorUser } = await supabase.auth.admin.getUserById(userId)
|
||||
const priorMeta = priorUser?.user?.app_metadata ?? {}
|
||||
await supabase.auth.admin.updateUserById(ctx.userId, {
|
||||
await supabase.auth.admin.updateUserById(userId, {
|
||||
app_metadata: { ...priorMeta, bankid_linked: true },
|
||||
})
|
||||
|
||||
@@ -1219,12 +1235,15 @@ export const ticExtension: Extension = {
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/unlink',
|
||||
// skipAuth: false, requires existing Supabase session
|
||||
handler: async (_request: Request, ctx?) => {
|
||||
// Requires a Supabase session but NOT a company: see /bankid/link.
|
||||
// A brand-new BankID signup (zero companies) must be able to undo
|
||||
// the connection from /settings/account.
|
||||
skipCompanyContext: true,
|
||||
handler: async (_request: Request) => {
|
||||
try {
|
||||
if (!ctx?.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) return auth.error
|
||||
const userId = auth.user.id
|
||||
|
||||
const supabase = createServiceClient()
|
||||
|
||||
@@ -1232,7 +1251,7 @@ export const ticExtension: Extension = {
|
||||
const { error: deleteError } = await supabase
|
||||
.from('bankid_identities')
|
||||
.delete()
|
||||
.eq('user_id', ctx.userId)
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (deleteError) {
|
||||
log.error('unlink delete failed', { message: deleteError.message, code: deleteError.code })
|
||||
@@ -1246,9 +1265,9 @@ export const ticExtension: Extension = {
|
||||
// user (has_password: false) would then be inferred as HAVING a
|
||||
// password (lib/auth/has-password.ts) and could strand themselves
|
||||
// with no working login method.
|
||||
const { data: priorUser } = await supabase.auth.admin.getUserById(ctx.userId)
|
||||
const { data: priorUser } = await supabase.auth.admin.getUserById(userId)
|
||||
const priorMeta = priorUser?.user?.app_metadata ?? {}
|
||||
await supabase.auth.admin.updateUserById(ctx.userId, {
|
||||
await supabase.auth.admin.updateUserById(userId, {
|
||||
app_metadata: { ...priorMeta, bankid_linked: false },
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Scrub auth.users metadata inside anonymize_user_account.
|
||||
--
|
||||
-- WHY
|
||||
-- ---
|
||||
-- app/api/account/delete/route.ts tried to wipe user_metadata/app_metadata
|
||||
-- after the RPC by calling auth.admin.updateUserById(userId, { user_metadata:
|
||||
-- {}, app_metadata: {} }). GoTrue MERGES metadata maps on admin update, so
|
||||
-- passing an empty object is a no-op: the tombstone kept the user's full name
|
||||
-- in raw_user_meta_data on a row we retain ~100 years (verified on production
|
||||
-- 2026-07-24). Anonymization must actually remove the PII, so the scrub moves
|
||||
-- into the SECURITY DEFINER function where a direct UPDATE is deterministic
|
||||
-- and atomic with the profile scrub.
|
||||
--
|
||||
-- raw_user_meta_data is cleared entirely (full_name, avatar, any provider
|
||||
-- leftovers). raw_app_meta_data only drops our app-specific keys
|
||||
-- (bankid_linked, has_password): provider/providers stay, GoTrue owns those.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.anonymize_user_account(target_user_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
blocker_count int;
|
||||
BEGIN
|
||||
IF auth.uid() IS DISTINCT FROM target_user_id THEN
|
||||
RAISE EXCEPTION 'Can only delete your own account';
|
||||
END IF;
|
||||
|
||||
-- Reject repeat invocations against an already-anonymized tombstone: the
|
||||
-- account is gone, re-running would only churn the scrubbed row.
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.profiles
|
||||
WHERE id = target_user_id AND anonymized_at IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Account is already deleted' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO blocker_count
|
||||
FROM public.company_members cm
|
||||
JOIN public.companies c ON c.id = cm.company_id
|
||||
WHERE cm.user_id = target_user_id
|
||||
AND cm.role = 'owner'
|
||||
AND c.archived_at IS NULL;
|
||||
|
||||
IF blocker_count > 0 THEN
|
||||
RAISE EXCEPTION 'Cannot delete account: user still owns % active compan(y/ies)', blocker_count
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
DELETE FROM public.company_members WHERE user_id = target_user_id;
|
||||
DELETE FROM public.team_members WHERE user_id = target_user_id;
|
||||
DELETE FROM public.bankid_identities WHERE user_id = target_user_id;
|
||||
|
||||
DELETE FROM public.user_preferences WHERE user_id = target_user_id;
|
||||
DELETE FROM public.api_keys WHERE user_id = target_user_id;
|
||||
|
||||
UPDATE public.profiles
|
||||
SET email = NULL,
|
||||
full_name = NULL,
|
||||
avatar_url = NULL,
|
||||
deleted_at = now(),
|
||||
anonymized_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = target_user_id;
|
||||
|
||||
-- Scrub PII from the auth tombstone. auth.users.email is intentionally
|
||||
-- kept (blocks re-signup + lets support verify identity for BFL-retained
|
||||
-- data recovery; documented legitimate interest, see
|
||||
-- app/api/account/delete/route.ts).
|
||||
UPDATE auth.users
|
||||
SET raw_user_meta_data = '{}'::jsonb,
|
||||
raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) - 'bankid_linked' - 'has_password'
|
||||
WHERE id = target_user_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.anonymize_user_account(uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.anonymize_user_account(uuid) TO authenticated;
|
||||
|
||||
-- Repair existing tombstones: every already-anonymized profile whose auth row
|
||||
-- still carries metadata. Guarded by anonymized_at so live users are untouched.
|
||||
-- The migration runner applies this whole file in a single transaction, so the
|
||||
-- UPDATE is atomic: it either scrubs all matching rows or none.
|
||||
UPDATE auth.users u
|
||||
SET raw_user_meta_data = '{}'::jsonb,
|
||||
raw_app_meta_data = coalesce(u.raw_app_meta_data, '{}'::jsonb) - 'bankid_linked' - 'has_password'
|
||||
FROM public.profiles p
|
||||
WHERE p.id = u.id
|
||||
AND p.anonymized_at IS NOT NULL
|
||||
AND (u.raw_user_meta_data <> '{}'::jsonb
|
||||
OR u.raw_app_meta_data ?| array['bankid_linked', 'has_password']);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -127,4 +127,57 @@ describe('account deletion RPCs (pg)', () => {
|
||||
})
|
||||
// withUserContext rolls back, so the seeded rows do not leak.
|
||||
})
|
||||
|
||||
it('scrubs auth.users metadata: user_metadata wiped, app keys dropped, provider kept', async () => {
|
||||
// Migration 20260724150000: the route-level updateUserById "wipe" was a
|
||||
// silent no-op (GoTrue merges metadata maps), so the tombstone kept the
|
||||
// user's full name. The RPC now scrubs auth.users directly. Email must
|
||||
// survive: it is the documented legitimate-interest tombstone.
|
||||
const userId = await insertAuthUser()
|
||||
await getPool().query(
|
||||
`UPDATE auth.users
|
||||
SET raw_user_meta_data = '{"full_name": "PG Real Person", "email_verified": true}'::jsonb,
|
||||
raw_app_meta_data = '{"provider": "email", "providers": ["email"], "bankid_linked": true, "has_password": false}'::jsonb
|
||||
WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await client.query('SELECT public.anonymize_user_account($1)', [userId])
|
||||
// The authenticated role has no SELECT on auth.users; drop back to the
|
||||
// superuser session user to verify. Still inside the same transaction,
|
||||
// so the rolled-back writes remain visible.
|
||||
await client.query('RESET ROLE')
|
||||
const { rows } = await client.query<{
|
||||
email: string | null
|
||||
user_meta: Record<string, unknown>
|
||||
app_meta: Record<string, unknown>
|
||||
}>(
|
||||
`SELECT email, raw_user_meta_data AS user_meta, raw_app_meta_data AS app_meta
|
||||
FROM auth.users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.email).toBe(`pg-real-${userId}@test.invalid`)
|
||||
expect(rows[0]!.user_meta).toEqual({})
|
||||
expect(rows[0]!.app_meta).toEqual({ provider: 'email', providers: ['email'] })
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a repeat invocation against an already-anonymized tombstone', async () => {
|
||||
const userId = await insertAuthUser()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.profiles (id, email, full_name)
|
||||
VALUES ($1, $2, 'PG Real')
|
||||
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, full_name = EXCLUDED.full_name`,
|
||||
[userId, `pg-real-${userId}@test.invalid`],
|
||||
)
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await client.query('SELECT public.anonymize_user_account($1)', [userId])
|
||||
await expect(
|
||||
client.query('SELECT public.anonymize_user_account($1)', [userId]),
|
||||
).rejects.toThrow(/already deleted/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user