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:
Jakob Wennberg
2026-07-27 17:00:15 +02:00
committed by GitHub
parent ff205951b1
commit 46c0b72ab0
10 changed files with 557 additions and 11 deletions
+200
View File
@@ -0,0 +1,200 @@
/**
* Support action: unlink a BankID identity from an account (#1231).
*
* WHY: when a user's personnummer is linked to a stale/abandoned account,
* BankID login strands them there (the Chillen support case, 2026-07-27) and
* /bankid/link on their real account returns 409 already_linked. The safe
* support fix is to unlink the stale account: that grants nobody access
* (re-linking still requires a password login to the target account plus a
* live BankID session), it just frees the personnummer.
*
* What it does on --execute:
* 1. writes an append-only audit_log row (SECURITY_EVENT) carrying the full
* old row FIRST: if the audit insert fails nothing is deleted, and if the
* delete fails the audit row merely over-records (the safe direction),
* 2. deletes the bankid_identities row for the user,
* 3. clears app_metadata.bankid_linked (read-merge-write: updateUserById
* replaces app_metadata wholesale, see app/api/account/password/route.ts).
*
* The dry run prints only non-sensitive account context (never the
* personnummer hash or ciphertext: the unsalted hash is brute-forceable
* over the small personnummer space). The restore path is the audit_log
* row's old_state, readable with the service key.
*
* Usage:
* npx tsx scripts/support/unlink-bankid.ts --email user@example.se --reason "GH-1234" # dry run
* npx tsx scripts/support/unlink-bankid.ts --email user@example.se --reason "GH-1234" --execute # performs the unlink
* (accepts --user-id <uuid> instead of --email when the profile has no email)
*
* Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY from .env.local.
* Treat .env.local as pointing at PRODUCTION: the dry run is read-only.
*/
import { createClient } from '@supabase/supabase-js'
import { config as dotenv } from 'dotenv'
import { resolve } from 'node:path'
dotenv({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SERVICE_KEY) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
function argValue(flag: string): string | null {
const i = process.argv.indexOf(flag)
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : null
}
const EXECUTE = process.argv.includes('--execute')
const EMAIL = argValue('--email')?.trim().toLowerCase() ?? null
const USER_ID = argValue('--user-id')?.trim() ?? null
const REASON = argValue('--reason')?.trim() ?? null
if (!EMAIL && !USER_ID) {
console.error('Usage: npx tsx scripts/support/unlink-bankid.ts --email <email> [--reason <ref>] [--execute]')
process.exit(1)
}
if (EXECUTE && !REASON) {
console.error('--execute requires --reason (support ticket / issue reference for the audit log)')
process.exit(1)
}
const sb = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } })
async function main() {
// Resolve the user. profiles mirrors auth emails for active accounts;
// anonymized accounts may lack it, hence the --user-id escape hatch.
let userId = USER_ID
if (!userId) {
const { data: profile, error } = await sb
.from('profiles')
.select('id, email')
.eq('email', EMAIL)
.maybeSingle()
if (error) {
console.error('profiles lookup failed:', error.message)
process.exit(1)
}
if (!profile) {
console.error(`No profile with email ${EMAIL}. If the account is anonymized, pass --user-id.`)
process.exit(1)
}
userId = profile.id
}
if (!userId) {
console.error('Could not resolve a user id')
process.exit(1)
}
const { data: authUser, error: authError } = await sb.auth.admin.getUserById(userId)
if (authError || !authUser?.user) {
console.error('auth user not found:', authError?.message ?? userId)
process.exit(1)
}
const { data: identity, error: identityError } = await sb
.from('bankid_identities')
.select('id, user_id, personal_number_hash, personal_number_enc, given_name, surname, linked_at, created_at, updated_at')
.eq('user_id', userId)
.maybeSingle()
if (identityError) {
console.error('bankid_identities lookup failed:', identityError.message)
process.exit(1)
}
if (!identity) {
console.log(`No BankID identity linked to ${authUser.user.email} (${userId}). Nothing to do.`)
process.exit(0)
}
// Account context so the operator can confirm this is the STALE account
// (the expected shape: few companies, no journal entries). Fail closed:
// a failed context query must never make an unknown account look empty.
const { data: memberships, error: membershipsError } = await sb
.from('company_members')
.select('company_id, companies:company_id(name, org_number)')
.eq('user_id', userId)
if (membershipsError) {
console.error('company_members lookup failed, aborting:', membershipsError.message)
process.exit(1)
}
const companyIds = (memberships ?? []).map((m) => m.company_id)
let entryCount = 0
if (companyIds.length > 0) {
const { count, error: entriesError } = await sb
.from('journal_entries')
.select('*', { count: 'exact', head: true })
.in('company_id', companyIds)
if (entriesError || count === null) {
console.error('journal_entries count failed, aborting:', entriesError?.message ?? 'null count')
process.exit(1)
}
entryCount = count
}
console.log('-- BankID unlink ------------------------------------------')
console.log('account: ', authUser.user.email, `(${userId})`)
console.log('bankid holder: ', [identity.given_name, identity.surname].filter(Boolean).join(' '))
console.log('linked since: ', identity.linked_at)
console.log('companies: ', (memberships ?? []).map((m) => {
const c = m.companies as unknown as { name?: string; org_number?: string } | null
return `${c?.name ?? '?'} (${c?.org_number ?? 'no orgnr'})`
}).join(', ') || 'none')
console.log('journal entries:', entryCount)
if (entryCount > 0) {
console.log('WARNING: this account has real bookkeeping. Unlinking BankID from an')
console.log('ACTIVE account is unusual: double-check you have the right one.')
}
console.log('identity row id: ', identity.id)
console.log('-----------------------------------------------------------')
if (!EXECUTE) {
console.log('Dry run. Re-run with --reason <ref> --execute to unlink.')
return
}
// Append-only audit trail FIRST, so a partial failure can never leave a
// deletion without a trace. The full old row (including hash + ciphertext)
// lives only here, RLS-protected; user_id = the affected user, so the
// entry is visible to them under the audit_log RLS select policy.
const { error: auditError } = await sb.from('audit_log').insert({
user_id: userId,
action: 'SECURITY_EVENT',
table_name: 'bankid_identities',
record_id: identity.id,
old_state: identity,
description: `support unlink-bankid (delete follows this entry): ${REASON}`,
})
if (auditError) {
console.error('audit_log insert failed, aborting BEFORE delete. Nothing changed.')
console.error('Error:', auditError.message)
process.exit(1)
}
const { error: deleteError } = await sb
.from('bankid_identities')
.delete()
.eq('id', identity.id)
if (deleteError) {
console.error('DELETE failed AFTER the audit row was written: the audit entry')
console.error(`(record_id ${identity.id}) over-records; the identity row still exists.`)
console.error('Error:', deleteError.message)
process.exit(1)
}
// Clear the settings-page "BankID linked" flag. Merge, never replace.
const priorMeta = authUser.user.app_metadata ?? {}
const { error: metaError } = await sb.auth.admin.updateUserById(userId, {
app_metadata: { ...priorMeta, bankid_linked: false },
})
if (metaError) {
console.error('app_metadata update failed (unlink itself succeeded):', metaError.message)
}
console.log('Unlinked. Restore path: audit_log old_state for record_id', identity.id)
console.log('The user can now link BankID from their other account:')
console.log('password login there, then Inställningar → Konto → koppla BankID.')
}
main()