feat(auth): surface duplicate-account traps around BankID login (#1234)
* feat(auth): surface duplicate-account traps around BankID login Three escape hatches for the stale-duplicate-account trap (#1231, the Chillen support case): a user whose BankID resolves to an abandoned account got an empty app with no hint that their real bookkeeping lives in another account. - check-org-number: new exists_elsewhere signal (service role, reduced to one boolean) + a warn chip in the onboarding journey when the org number already exists in an account the user is not a member of. - Hem: one AttnLine under the greeting when the whole account has zero journal entries but a same-orgnr company elsewhere has real bookkeeping, with a sign-out action. Common case costs one indexed existence probe. - scripts/support/unlink-bankid.ts: dry-run-by-default support action that unlinks a BankID identity (delete + app_metadata clear + append-only SECURITY_EVENT audit_log row). Replaces the raw SQL used to resolve the original ticket. Closes #1231 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): harden unlink script and paginate hint queries per review - other-account-hint: fetchAllRows() on both company listings (PostgREST 1000-row cap; byrå users can hold many memberships); the journal probes stay limit(1) existence checks. - unlink-bankid: audit_log row is written BEFORE the delete so a partial failure can never delete without a trace; context queries fail closed instead of rendering an unknown account as empty; stdout no longer prints the personnummer hash or ciphertext (the unsalted hash is brute-forceable over the personnummer space); record_id now carries the identity row id and the snapshot includes id + linked_at. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { shouldShowOtherAccountHint } from '../other-account-hint'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const mockCreateServiceClient = vi.mocked(createServiceClient)
|
||||
|
||||
/**
|
||||
* Chainable query mock keyed by table name: every method returns the chain,
|
||||
* awaiting it resolves with the configured { data, error } for that table.
|
||||
*/
|
||||
function buildClient(resultsByTable: Record<string, { data?: unknown; error?: unknown }>) {
|
||||
return {
|
||||
from: vi.fn((table: string) => {
|
||||
const result = {
|
||||
data: resultsByTable[table]?.data ?? null,
|
||||
error: resultsByTable[table]?.error ?? null,
|
||||
}
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'is', 'limit', 'order', 'range']) {
|
||||
chain[m] = () => chain
|
||||
}
|
||||
;(chain as { then?: unknown }).then = (resolve: (v: unknown) => void) => resolve(result)
|
||||
return chain
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const OWN_COMPANY = { id: 'own-co', org_number: '5560125790' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateServiceClient.mockReturnValue(
|
||||
buildClient({ companies: { data: [] }, journal_entries: { data: [] } }) as never,
|
||||
)
|
||||
})
|
||||
|
||||
describe('shouldShowOtherAccountHint', () => {
|
||||
it('is false when the account has journal entries (common case, no probe)', async () => {
|
||||
const supabase = buildClient({
|
||||
companies: { data: [OWN_COMPANY] },
|
||||
journal_entries: { data: [{ id: 'je1' }] },
|
||||
})
|
||||
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
|
||||
expect(mockCreateServiceClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is true when the account is empty and a same-orgnr company elsewhere has entries', async () => {
|
||||
const supabase = buildClient({
|
||||
companies: { data: [OWN_COMPANY] },
|
||||
journal_entries: { data: [] },
|
||||
})
|
||||
mockCreateServiceClient.mockReturnValue(
|
||||
buildClient({
|
||||
companies: { data: [{ id: 'own-co' }, { id: 'other-co' }] },
|
||||
journal_entries: { data: [{ id: 'je-other' }] },
|
||||
}) as never,
|
||||
)
|
||||
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the same-orgnr company elsewhere is also empty', async () => {
|
||||
const supabase = buildClient({
|
||||
companies: { data: [OWN_COMPANY] },
|
||||
journal_entries: { data: [] },
|
||||
})
|
||||
mockCreateServiceClient.mockReturnValue(
|
||||
buildClient({
|
||||
companies: { data: [{ id: 'own-co' }, { id: 'other-co' }] },
|
||||
journal_entries: { data: [] },
|
||||
}) as never,
|
||||
)
|
||||
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is false when no other account shares the org number', async () => {
|
||||
const supabase = buildClient({
|
||||
companies: { data: [OWN_COMPANY] },
|
||||
journal_entries: { data: [] },
|
||||
})
|
||||
mockCreateServiceClient.mockReturnValue(
|
||||
buildClient({
|
||||
companies: { data: [{ id: 'own-co' }] },
|
||||
journal_entries: { data: [{ id: 'je-other' }] },
|
||||
}) as never,
|
||||
)
|
||||
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is false when the user has no companies', async () => {
|
||||
const supabase = buildClient({ companies: { data: [] }, journal_entries: { data: [] } })
|
||||
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('is false when own companies have no org number', async () => {
|
||||
const supabase = buildClient({
|
||||
companies: { data: [{ id: 'own-co', org_number: null }] },
|
||||
journal_entries: { data: [] },
|
||||
})
|
||||
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
|
||||
expect(mockCreateServiceClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails soft to false on query errors', async () => {
|
||||
const supabase = buildClient({
|
||||
companies: { error: { message: 'boom' } },
|
||||
journal_entries: { data: [] },
|
||||
})
|
||||
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('fails soft to false when the service client throws', async () => {
|
||||
const supabase = buildClient({
|
||||
companies: { data: [OWN_COMPANY] },
|
||||
journal_entries: { data: [] },
|
||||
})
|
||||
mockCreateServiceClient.mockImplementation(() => {
|
||||
throw new Error('no service key')
|
||||
})
|
||||
await expect(shouldShowOtherAccountHint(supabase as unknown as SupabaseClient)).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
/**
|
||||
* Should the Hem page hint that the user may be signed in to the wrong
|
||||
* account? (#1231, the "Chillen" support case: BankID resolved to a stale
|
||||
* signup account while all real bookkeeping lived in a second
|
||||
* email+password account with the same org number.)
|
||||
*
|
||||
* True only when BOTH hold:
|
||||
* 1. Every company the caller can see has zero journal entries (their
|
||||
* account is bookkeeping-empty), and
|
||||
* 2. a company with one of the same org numbers, in an account they are
|
||||
* NOT a member of, has at least one journal entry.
|
||||
*
|
||||
* The common case (an account with any bookkeeping at all) exits after one
|
||||
* indexed existence probe. The cross-account probe runs on the service
|
||||
* client but the result reduces to one boolean: nothing about the other
|
||||
* account is revealed beyond "your bookkeeping may live elsewhere".
|
||||
* Fails soft to false: this is an advisory line, never worth an error.
|
||||
*/
|
||||
export async function shouldShowOtherAccountHint(supabase: SupabaseClient): Promise<boolean> {
|
||||
try {
|
||||
// RLS scopes both reads to the caller's memberships. Company lists are
|
||||
// paginated with fetchAllRows (PostgREST caps at 1000 rows; a byrå user
|
||||
// can belong to many companies); it throws on error, which the outer
|
||||
// catch turns into false. The journal_entries read stays a bare
|
||||
// limit(1): it is an existence probe, not a listing.
|
||||
const [ownCompanies, { data: ownEntries, error: entriesError }] = await Promise.all([
|
||||
fetchAllRows<{ id: string; org_number: string | null }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('companies')
|
||||
.select('id, org_number')
|
||||
.is('archived_at', null)
|
||||
.order('id')
|
||||
.range(from, to),
|
||||
),
|
||||
supabase.from('journal_entries').select('id').limit(1),
|
||||
])
|
||||
|
||||
if (entriesError) return false
|
||||
if (ownCompanies.length === 0) return false
|
||||
if ((ownEntries ?? []).length > 0) return false
|
||||
|
||||
const ownIds = new Set(ownCompanies.map((c) => c.id))
|
||||
const orgNumbers = [
|
||||
...new Set(ownCompanies.map((c) => c.org_number).filter((n): n is string => Boolean(n))),
|
||||
]
|
||||
if (orgNumbers.length === 0) return false
|
||||
|
||||
const service = createServiceClient()
|
||||
const sameOrgCompanies = await fetchAllRows<{ id: string }>(({ from, to }) =>
|
||||
service
|
||||
.from('companies')
|
||||
.select('id')
|
||||
.in('org_number', orgNumbers)
|
||||
.is('archived_at', null)
|
||||
.order('id')
|
||||
.range(from, to),
|
||||
)
|
||||
|
||||
const otherIds = sameOrgCompanies.map((c) => c.id).filter((id) => !ownIds.has(id))
|
||||
if (otherIds.length === 0) return false
|
||||
|
||||
const { data: otherEntries, error: otherEntriesError } = await service
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.in('company_id', otherIds)
|
||||
.limit(1)
|
||||
|
||||
if (otherEntriesError) return false
|
||||
return (otherEntries ?? []).length > 0
|
||||
} catch {
|
||||
// Service key unavailable (some self-hosted setups) or transient failure.
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user