fix(security): audit remediation 2026-09-01: api_keys identity, viewer gates, OAuth binding, XSS, MFA gate (#2155)

* fix(security): bind api_keys to the caller, lock hash-as-bearer RPCs and provider token tables

Security audit 2026-09-01, critical items.

- api_keys INSERT requires user_id = auth.uid() again (an admin could
  forge a key for any co-member and act as them in every company they
  belong to); SELECT is own-keys-or-admin; a BEFORE trigger freezes the
  identity and credential columns against user-session UPDATEs.
- rotate_mcp_refresh_token and validate_and_increment_api_key become
  service_role only: they match rows by a presented SHA-256, so a hash
  readable by co-members was a bearer credential.
- validate_and_increment_api_key fails closed when the key's user is no
  longer a member of the key's company.
- provider_consent_tokens and provider_otc: the DELETE policies collapsed
  to "caller has any team row" (correlated subquery on a non-existent
  team_members.company_id). All member policies dropped; service_role
  only, matching every existing code path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): role gates, ownership guards and posting integrity in the database

Security audit 2026-09-01, high items at the database layer.

- One table-level guard, enforce_company_writer_role(), blocks the
  read-only viewer role on 55 company-scoped tables including through
  the 15 membership-only SECURITY DEFINER writers. Keyed on the JWT role
  claim so it fires inside definer bodies; no-op for service_role and
  trigger cascades.
- company_members user_id/company_id immutable from user sessions;
  invitations can never grant owner; team_members gains a transition
  guard (admins keep non-owner role moves); companies team_id and
  archiving are owner-only and team attachment needs team membership.
- Direct statements (current_user = authenticated) can no longer insert
  posted headers, add lines under posted verifikat, or post a draft with
  a voucher number the sequence never issued. Sanctioned RPCs run as the
  definer and are untouched; the engine's own draft-then-post shapes
  still pass.
- create_document_version refuses viewers and foreign storage paths;
  validate_version_chain needs membership and loses anon EXECUTE;
  match_documents / match_booking_templates lose anon; cron maintenance
  RPCs become service_role only; the production-only
  seed_asset_categories is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* build: pin tsx as an exact devDependency instead of fetching it with npx at build time

prebuild ran "npx tsx" with no lockfile entry, so every Vercel, Docker
and CI build downloaded tsx@latest and its transitive tree from the
registry with no integrity check, inside the build environment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): refuse the viewer role on API-key and MCP write paths

The v1 wrapper and the MCP company routing checked company membership
but never role, and both run as service role, so a read-only viewer
holding an API key could post vouchers and change settings through the
API. Mutating methods and non-read scopes now return 403 ROLE_READ_ONLY
for viewers on v1; MCP write tools refuse viewers the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): stop serving uploaded SVG, XML and HTML as executable content on the app origin

Uploads persisted the browser-declared mime type and the inline proxy
served it verbatim, sandboxing only text/html; the storage proxy
forwarded the uploader's Content-Type. Any writer, or any Peppol sender,
could plant a scripted SVG or XHTML that executed on app.gnubok.se.

- inline route: allow-list of natively safe types (PDF, raster images)
  served as before; everything else gets the opaque sandbox CSP.
- storage proxy: octet-stream + attachment + sandbox unless the DB
  mime for the key is on the allow-list.
- document-service: the stored mime is the magic-byte validated type.
- logo upload: magic-byte validation, SVG refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): byrå brand logo upload decides the type by magic bytes and drops SVG

Same pattern as the company logo route: the logos bucket is public, so a
scripted SVG (or anything declared as an image) must never land there.
The upload pickers stop advertising SVG.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind Enable Banking, Stripe and WooCommerce callbacks to the initiating user

The callbacks resolved the pending row by oauth_state alone, so a
victim who completed an attacker-initiated consent had their bank
account, merchant account or store attached to the attacker's company.
requireFlowInitiator() now requires the cookie session of the user who
started the flow: no session redirects to login with the callback URL
preserved, a different user is refused and nothing is exchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): guard tenant-controlled outbound fetches and surface the disabled rate limiter

WooCommerce and Shopify syncs fetched a member-editable store URL with
plain fetch() and redirect following under the service role, and the
invoice PDF renderer fetched company_settings.logo_url unguarded. All
three go through a new safeFetch() (public-IP validation via url-guard,
https only, redirect: 'manual', body size cap) and re-normalise the
stored host at use time. checkRateLimit() keeps failing open on hosted
but logs one error per process when Upstash is not configured and
exports isRateLimiterConfigured().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): decide the API MFA gate from server-authenticated factors, not the session cookie

getAuthenticatorAssuranceLevel() without arguments derives nextLevel
from session.user.factors, which comes from the unsigned sb-*-auth-token
cookie. Deleting factors from the cookie made an enrolled account look
like it had nothing to step up to, on every /api route and in
requireAuth. Both gates now read factors from the getUser() result or
listFactors() and the level from the verified JWT claim, and fail closed
on errors. Page-branch gate hardened the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind Fortnox/Visma, Gmail and Skatteverket callbacks to the initiating user

The arcim-migration callback exchanged the provider code onto whatever
consent the one-time state named, with no check of who completed the
flow and no org-number comparison, so a phished Fortnox admin handed
their ledger to the attacker's company. provider_otc now records the
initiating user (migration 20260902100000); the callback requires that
session and, after the exchange, refuses a provider company whose org
number differs from the consent's company. The Gmail and Skatteverket
callbacks enforce the same initiator check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): BankID signup confirms the email before linking the identity

Signup created an email-confirmed, MFA-exempt account for any address
the caller typed and returned a magic link, so an attacker could
pre-register a victim's email and keep a permanent BankID login into the
account the victim later adopted. The user is now created unconfirmed,
the identity carries email_verified_at NULL (migration 20260902101000),
bankid_linked is not set until the mailed confirmation is clicked, and
BankID login of a pending identity is refused with the confirmation
re-sent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind MCP OAuth redirect URIs to the consenting user and cap scopes

A user-registered redirect URI was allowlisted globally, the consent page
named no client, and all scopes were pre-checked, so one phishing link
handed an attacker a full-scope key for the victim's company. Registered
URIs now resolve only for the registrant or a colleague sharing a
company; the consent page shows the client identity and redirect host;
non-built-in clients default to read-only pre-checks; scopes are capped
by the user's role (viewer: read only) at consent and at /token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(auth): client follow-ups for BankID confirmation, callback mismatch copy and decision log

- register client handles the new confirmation_sent response from BankID
  signup with the existing inbox screen instead of calling verifyOtp.
- BankID login surfaces the email_unconfirmed explanation.
- WooCommerce settings map woocommerce_error=wrong_user to its own copy.
- Logo help text no longer advertises SVG.
- DECISIONS.md records the audit remediation choices.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp-oauth): literal SoD columns in the api_keys insert so the phantom-column scanner resolves them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(logo): type the upload fixtures as Uint8Array<ArrayBuffer> so they are valid BlobParts

Fixes the typecheck ratchet on PR #2155 and ratchets the baseline down
by the one legacy error the change removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 11:38:30 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 6e8d76a9cb
commit 18cbc4c30a
92 changed files with 10355 additions and 657 deletions
@@ -15,6 +15,21 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
// The confirmation mail (generateLink + platform email service + brand
// lookup) is covered in bankid-confirmation-mail.test.ts; here it is a seam
// so the route tests can assert WHEN it is sent and to WHOM.
vi.mock('../lib/bankid-confirmation-mail', () => ({
sendBankIdSignupConfirmation: vi.fn(),
}))
// The invite-only brand gate reads the brands table for any non-empty host;
// it has its own tests. Open here so a forwarded host can be asserted on the
// mail without a database.
vi.mock('@/lib/auth/brand-signup-gate', () => ({
evaluateBrandSignupGate: vi.fn().mockResolvedValue({ allowed: true }),
readInviteTokenFromCookieHeader: vi.fn().mockReturnValue(null),
}))
import {
cancelBankIdSession,
collectBankIdResult,
@@ -23,6 +38,7 @@ import {
fetchEnrichmentData,
startBankIdAuth,
} from '../lib/bankid-client'
import { sendBankIdSignupConfirmation } from '../lib/bankid-confirmation-mail'
import { createServiceClient } from '@/lib/supabase/server'
import { ticExtension } from '../index'
import {
@@ -141,9 +157,13 @@ function mockServiceClient(
return { admin, client }
}
/** A verified identity row, as every pre-2026-09 row is after the backfill. */
const VERIFIED_AT = '2026-01-01T00:00:00Z'
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('BANKID_ENCRYPTION_KEY', TEST_KEY)
vi.mocked(sendBankIdSignupConfirmation).mockResolvedValue({ ok: true })
})
afterEach(() => {
@@ -213,37 +233,71 @@ describe('POST /bankid/complete', () => {
})
})
describe('signup mode: happy path', () => {
it('creates a new user, marks bankid_linked, and returns the magic link tokenHash', async () => {
describe('signup mode: happy path (account pre-hijacking fix, audit 2026-09)', () => {
it('creates an UNCONFIRMED user with a PENDING identity, mails the typed address, and returns no session', async () => {
// The address came from the request body and nothing proved it belongs
// to the BankID holder. The old flow confirmed it, granted the MFA
// exemption and handed the browser a magic link; the real owner of the
// address could later adopt the account while the BankID kept a login.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
const { admin, client } = mockServiceClient([
{ data: null }, // pnr lookup → not linked
{ error: null }, // bankid_identities insert OK
])
const insertSpy = vi.fn().mockResolvedValue({ error: null })
const origFrom = client.from as unknown as ReturnType<typeof vi.fn>
const queuedFrom = origFrom.getMockImplementation() as (table: string) => unknown
let identityCalls = 0
origFrom.mockImplementation((table: string) => {
if (table === 'bankid_identities' && ++identityCalls === 2) {
return { insert: insertSpy }
}
return queuedFrom(table)
})
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'fresh@example.com' },
headers: { ...(await flowCookie('signup')), 'x-forwarded-host': 'app.gnubok.se', 'x-forwarded-proto': 'https' },
body: { email: 'Fresh@Example.com ' },
})
const response = await findCompleteHandler()(req)
const raw = await response.clone().text()
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; type?: string; isNewUser?: boolean }
}>(await findCompleteHandler()(req))
data?: { status?: string; email?: string; tokenHash?: string }
}>(response)
expect(status).toBe(200)
expect(body.data?.tokenHash).toBe('magic-token-hash')
expect(body.data?.type).toBe('magiclink')
expect(body.data?.isNewUser).toBe(true)
// Same shape as POST /api/auth/signup: the register page shows its
// "check your inbox" screen. Nothing here signs anyone in.
expect(body.data).toEqual({ status: 'confirmation_sent', email: 'fresh@example.com' })
expect(raw).not.toContain('magic-token-hash')
expect(raw).not.toContain('tokenHash')
// The route itself mints no link; only the mail helper does, server-side.
expect(admin.generateLink).not.toHaveBeenCalled()
expect(admin.createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'fresh@example.com', email_confirm: true })
expect.objectContaining({ email: 'fresh@example.com', email_confirm: false })
)
// Pending, not linked: bankid_linked (the MFA exemption) is granted by
// /auth/callback once the mail is clicked.
expect(admin.updateUserById).toHaveBeenCalledWith(
'new-user-uuid',
expect.objectContaining({
app_metadata: { bankid_linked: true, has_password: false },
app_metadata: { bankid_pending: true, has_password: false },
})
)
expect(insertSpy).toHaveBeenCalledWith(
expect.objectContaining({ user_id: 'new-user-uuid', email_verified_at: null })
)
expect(insertSpy.mock.calls[0][0]).not.toHaveProperty('bankid_linked')
expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1)
expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith({
supabase: client,
email: 'fresh@example.com',
host: 'app.gnubok.se',
proto: 'https',
})
expect(admin.deleteUser).not.toHaveBeenCalled()
})
})
@@ -251,7 +305,7 @@ describe('POST /bankid/complete', () => {
it('returns 409 already_linked before email lookup', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'some-other-user' } }, // pnr lookup → LINKED
{ data: { user_id: 'some-other-user', email_verified_at: VERIFIED_AT } }, // pnr lookup → LINKED
])
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
@@ -300,28 +354,32 @@ describe('POST /bankid/complete', () => {
expect(admin.generateLink).not.toHaveBeenCalled()
})
it('deletes the created user when generateLink fails', async () => {
it('deletes the created user when the confirmation mail cannot be sent', async () => {
// An account whose address will never receive its confirmation link is
// unusable AND blocks the address; roll it back so a retry starts clean.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: null }, // pnr lookup → not linked
{ error: null }, // identity insert OK
])
admin.generateLink.mockResolvedValueOnce({
data: null,
error: { message: 'link boom' },
} as never)
vi.mocked(sendBankIdSignupConfirmation).mockResolvedValueOnce({
ok: false,
step: 'send',
message: 'Email service not configured',
})
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
const { status, body } = await parseJsonResponse<{ error?: string; message?: string }>(
await findCompleteHandler()(req)
)
expect(status).toBe(500)
expect(body.error).toBe('internal_error')
expect(body.message).toBe('Kunde inte skicka bekräftelsemailet. Försök igen.')
expect(admin.deleteUser).toHaveBeenCalledWith('new-user-uuid')
})
@@ -388,6 +446,323 @@ describe('POST /bankid/complete', () => {
expect(body.error).toBe('no_account')
expect(admin.generateLink).not.toHaveBeenCalled()
})
it('answers 503 (not no_account) when the identity lookup itself fails', async () => {
// A schema behind the code or a lost connection must not send every
// returning BankID user to signup, nor create anything.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: null, error: { code: '42703', message: 'column "email_verified_at" does not exist' } },
])
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(503)
expect(body.error).toBe('service_unavailable')
expect(admin.generateLink).not.toHaveBeenCalled()
expect(admin.createUser).not.toHaveBeenCalled()
})
it('treats PGRST116 (no row) as not linked', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
mockServiceClient([
{ data: null, error: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' } },
])
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(404)
expect(body.error).toBe('no_account')
})
it('signs a VERIFIED identity in with a magic link, as before', async () => {
// Every identity that existed before the pending column was added is
// backfilled as verified; their login must be byte-identical.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } },
])
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; type?: string; isNewUser?: boolean }
}>(await findCompleteHandler()(req))
expect(status).toBe(200)
expect(body.data).toEqual({ tokenHash: 'magic-token-hash', type: 'magiclink', isNewUser: false })
expect(admin.generateLink).toHaveBeenCalledWith({
type: 'magiclink',
email: 'existing@example.com',
})
expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled()
})
})
describe('pending identities (address never proven, audit 2026-09)', () => {
/** The auth user a BankID signup leaves behind until the mail is clicked. */
const pendingShell = {
id: 'pending-user',
email: 'typed@example.com',
email_confirmed_at: undefined,
identities: [{ provider: 'email' }],
app_metadata: { bankid_pending: true, has_password: false },
}
function clearedFlow(response: Response): boolean {
return response.headers
.getSetCookie()
.some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c))
}
it('login: refuses a pending identity, re-sends the confirmation mail, and mints nothing', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'pending-user', email_verified_at: null } },
])
admin.getUserById.mockResolvedValue({ data: { user: pendingShell }, error: null } as never)
const response = await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: { ...(await flowCookie('login')), 'x-forwarded-host': 'app.gnubok.se' },
})
)
const raw = await response.clone().text()
const { status, body } = await parseJsonResponse<{ error?: string; message?: string }>(response)
expect(status).toBe(403)
expect(body.error).toBe('email_unconfirmed')
expect(body.message).toMatch(/^Bekräfta din e-postadress först/)
expect(raw).not.toContain('tokenHash')
expect(admin.generateLink).not.toHaveBeenCalled()
expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith(
expect.objectContaining({ email: 'typed@example.com', host: 'app.gnubok.se' })
)
// The flag was already set, so nothing to heal; the row stays for the click.
expect(admin.updateUserById).not.toHaveBeenCalled()
expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual(['bankid_identities'])
// Terminal for this identification: the flow is spent.
expect(clearedFlow(response)).toBe(true)
})
it('login: heals a missing bankid_pending flag before re-sending (rows from the old flow)', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'legacy-user', email_verified_at: null } },
])
admin.getUserById.mockResolvedValue({
data: {
user: {
...pendingShell,
id: 'legacy-user',
email_confirmed_at: '2026-09-02T08:00:00Z',
app_metadata: { bankid_linked: true, has_password: false },
},
},
error: null,
} as never)
const { status } = await parseJsonResponse(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(403)
// /auth/callback only promotes when the flag is present.
expect(admin.updateUserById).toHaveBeenCalledWith('legacy-user', {
app_metadata: { bankid_linked: true, has_password: false, bankid_pending: true },
})
expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1)
})
it('login: revokes the pending link and answers no_account once the address owner adopted the account', async () => {
// The victim signed in with Google (or set a password): the account is
// theirs. The BankID holder who typed their address gets no login into
// it, now or after any later confirmation click.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'victim-user', email_verified_at: null } }, // pnr lookup → pending
{ error: null }, // bankid_identities delete OK
])
admin.getUserById.mockResolvedValue({
data: {
user: {
...pendingShell,
id: 'victim-user',
email: 'victim@example.com',
identities: [{ provider: 'email' }, { provider: 'google' }],
},
},
error: null,
} as never)
const { status, body } = await parseJsonResponse<{ error?: string; givenName?: string }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(404)
expect(body.error).toBe('no_account')
expect(body.givenName).toBe('Anna')
expect(admin.generateLink).not.toHaveBeenCalled()
expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled()
// Row deleted, flag dropped, no MFA exemption granted.
expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual([
'bankid_identities',
'bankid_identities',
])
expect(admin.updateUserById).toHaveBeenCalledWith('victim-user', {
app_metadata: { bankid_pending: null, has_password: false },
})
expect(admin.deleteUser).not.toHaveBeenCalled()
})
it('login: a pending identity whose user set a password is treated as adopted', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'victim-user', email_verified_at: null } },
{ error: null },
])
admin.getUserById.mockResolvedValue({
data: {
user: { ...pendingShell, id: 'victim-user', app_metadata: { bankid_pending: true, has_password: true } },
},
error: null,
} as never)
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(404)
expect(body.error).toBe('no_account')
expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled()
})
it('signup: replaces a stale unadopted pending account from the same BankID (typo, lost mail)', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'stale-user', email_verified_at: null } }, // pnr lookup → pending
{ error: null }, // new bankid_identities insert OK
])
admin.getUserById.mockResolvedValue({
data: { user: { ...pendingShell, id: 'stale-user', email: 'typo@exmaple.com' } },
error: null,
} as never)
const { status, body } = await parseJsonResponse<{ data?: { status?: string } }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'correct@example.com' },
})
)
)
expect(status).toBe(200)
expect(body.data?.status).toBe('confirmation_sent')
// The unconfirmed shell (cascades its identity row) goes; a fresh one is made.
expect(admin.deleteUser).toHaveBeenCalledTimes(1)
expect(admin.deleteUser).toHaveBeenCalledWith('stale-user')
expect(admin.createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'correct@example.com', email_confirm: false })
)
expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith(
expect.objectContaining({ email: 'correct@example.com' })
)
})
it('signup: never deletes an adopted account; only the pending link is removed before the new signup', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'victim-user', email_verified_at: null } }, // pnr lookup → pending
{ error: null }, // pending row delete OK
{ error: null }, // new bankid_identities insert OK
])
admin.getUserById.mockResolvedValue({
data: {
user: {
...pendingShell,
id: 'victim-user',
email_confirmed_at: '2026-09-01T00:00:00Z',
identities: [{ provider: 'email' }, { provider: 'google' }],
},
},
error: null,
} as never)
const { status } = await parseJsonResponse(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'mine@example.com' },
})
)
)
expect(status).toBe(200)
expect(admin.deleteUser).not.toHaveBeenCalled()
expect(vi.mocked(client.from).mock.calls.filter((c) => c[0] === 'bankid_identities')).toHaveLength(3)
expect(admin.updateUserById).toHaveBeenCalledWith('victim-user', {
app_metadata: { bankid_pending: null, has_password: false },
})
expect(admin.createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'mine@example.com' })
)
})
it('signup: fails closed (500, flow kept) when the stale pending link cannot be cleared', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'stale-user', email_verified_at: null } },
])
admin.getUserById.mockResolvedValue({ data: { user: { ...pendingShell, id: 'stale-user' } }, error: null } as never)
admin.deleteUser.mockResolvedValueOnce({ data: null, error: { message: 'boom' } } as never)
const response = await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'correct@example.com' },
})
)
expect(response.status).toBe(500)
expect(admin.createUser).not.toHaveBeenCalled()
expect(clearedFlow(response)).toBe(false)
})
})
describe('enrichment: SPAR + CompanyRoles', () => {
@@ -457,11 +832,11 @@ describe('POST /bankid/complete', () => {
body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; isNewUser?: boolean }
data?: { status?: string }
}>(await findCompleteHandler()(req))
expect(status).toBe(200)
expect(body.data?.isNewUser).toBe(true)
expect(body.data?.status).toBe('confirmation_sent')
expect(vi.mocked(requestEnrichment)).toHaveBeenCalledWith(
'test-session',
['SPAR', 'CompanyRoles']
@@ -586,9 +961,9 @@ describe('POST /bankid/complete', () => {
expect(collectBankIdResult).not.toHaveBeenCalled()
})
it('spends the flow on success, so a second tab cannot mint a rival magic link', async () => {
it('spends the flow on success, so a second tab cannot send a rival confirmation mail', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
mockServiceClient([
{ data: null }, // pnr lookup → not linked
{ error: null }, // bankid_identities insert OK
])
@@ -602,9 +977,9 @@ describe('POST /bankid/complete', () => {
)
expect(response.status).toBe(200)
expect(admin.generateLink).toHaveBeenCalled()
expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1)
// Without this, two tabs that both saw 'complete' would each mint a
// magic link and the second would invalidate the first.
// link and the second would invalidate the first.
expect(clearedFlow(response)).toBe(true)
})
@@ -615,7 +990,7 @@ describe('POST /bankid/complete', () => {
// magic link that would invalidate the first tab's.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient(
[{ data: { user_id: 'existing-user' } }], // pnr is linked
[{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } }], // pnr is linked
{ error: { code: '23505', message: 'duplicate key' } },
)
@@ -662,7 +1037,7 @@ describe('POST /bankid/complete', () => {
// authenticate again, so an unreachable table must not be waved through.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient(
[{ data: { user_id: 'existing-user' } }],
[{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } }],
{ error: { code: '42P01', message: 'relation does not exist' } },
)
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const sendEmailMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ sendEmail: sendEmailMock, isConfigured: () => true }),
}))
const resolveBrandByHostMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandByHost: resolveBrandByHostMock,
// Imported by lib/email/brand-sender (not called on this path).
resolveBrandForCompany: vi.fn(),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.gnubok.se' }),
}))
import {
buildConfirmationUrl,
sendBankIdSignupConfirmation,
} from '../lib/bankid-confirmation-mail'
function serviceClient(generateLinkResult: unknown) {
const generateLink = vi.fn().mockResolvedValue(generateLinkResult)
return {
generateLink,
supabase: { auth: { admin: { generateLink } } } as unknown as SupabaseClient,
}
}
const LINK_OK = { data: { properties: { hashed_token: 'hashed-123' } }, error: null }
beforeEach(() => {
vi.clearAllMocks()
resolveBrandByHostMock.mockResolvedValue(null)
sendEmailMock.mockResolvedValue({ success: true, messageId: 'm-1' })
})
describe('buildConfirmationUrl', () => {
it('lands on the originating host with the token_hash + magiclink verify pattern', () => {
expect(buildConfirmationUrl('app.siffra.se', 'https', 'tok')).toBe(
'https://app.siffra.se/auth/callback?token_hash=tok&type=magiclink',
)
})
it('defaults to https when the proxy did not forward a protocol', () => {
expect(buildConfirmationUrl('app.siffra.se', null, 'tok')).toMatch(/^https:\/\/app\.siffra\.se\//)
})
it('falls back to the canonical app URL without a host', () => {
expect(buildConfirmationUrl('', undefined, 'tok')).toBe(
'https://app.gnubok.se/auth/callback?token_hash=tok&type=magiclink',
)
})
})
describe('sendBankIdSignupConfirmation', () => {
it('mints a magic link server-side and mails it to the typed address, never returning the token', async () => {
const { supabase, generateLink } = serviceClient(LINK_OK)
const result = await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: 'app.gnubok.se',
proto: 'https',
})
expect(result).toEqual({ ok: true })
expect(generateLink).toHaveBeenCalledWith({ type: 'magiclink', email: 'fresh@example.com' })
expect(sendEmailMock).toHaveBeenCalledTimes(1)
const mail = sendEmailMock.mock.calls[0][0]
expect(mail.to).toBe('fresh@example.com')
expect(mail.subject).toBe('Bekräfta din e-postadress')
expect(mail.text).toContain(
'https://app.gnubok.se/auth/callback?token_hash=hashed-123&type=magiclink',
)
expect(mail.text).toContain('BankID')
// Platform sender: no brand on the canonical host.
expect(mail.fromName).toBeUndefined()
expect(mail.fromAddress).toBeUndefined()
})
it('sends in the brand of the requesting host', async () => {
resolveBrandByHostMock.mockResolvedValue({
appName: 'Siffra',
domain: 'app.siffra.se',
supportEmail: 'support@siffra.se',
authEmailFrom: 'noreply@post.siffra.se',
senderDomainStatus: 'verified',
})
const { supabase } = serviceClient(LINK_OK)
await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: 'app.siffra.se',
proto: 'https',
})
expect(resolveBrandByHostMock).toHaveBeenCalledWith('app.siffra.se')
const mail = sendEmailMock.mock.calls[0][0]
expect(mail.fromName).toBe('Siffra')
expect(mail.fromAddress).toBe('noreply@post.siffra.se')
expect(mail.replyTo).toBe('support@siffra.se')
expect(mail.text).toContain('https://app.siffra.se/auth/callback?token_hash=hashed-123')
expect(mail.html).not.toMatch(/accounted/i)
})
it('reports a generateLink failure without sending anything', async () => {
const { supabase } = serviceClient({ data: null, error: { message: 'link boom', code: 'x' } })
const result = await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: '',
})
expect(result).toEqual({ ok: false, step: 'generate_link', message: 'link boom' })
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('reports a send failure so the caller can roll the signup back', async () => {
sendEmailMock.mockResolvedValue({ success: false, error: 'Email service not configured' })
const { supabase } = serviceClient(LINK_OK)
const result = await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: '',
})
expect(result).toEqual({ ok: false, step: 'send', message: 'Email service not configured' })
})
})
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
hasForeignCredential,
isUnadoptedPendingAccount,
revokePendingIdentity,
} from '../lib/bankid-pending'
function identity(provider: string) {
return { provider } as unknown as NonNullable<
Parameters<typeof hasForeignCredential>[0]['identities']
>[number]
}
describe('hasForeignCredential', () => {
it('is false for the shell a BankID signup makes (email identity, no password)', () => {
expect(
hasForeignCredential({
identities: [identity('email')],
app_metadata: { bankid_pending: true, has_password: false },
email_confirmed_at: undefined,
}),
).toBe(false)
})
it('is false when identities are absent altogether', () => {
expect(hasForeignCredential({ app_metadata: {} })).toBe(false)
})
it('is true once a Google identity is attached', () => {
expect(
hasForeignCredential({
identities: [identity('email'), identity('google')],
app_metadata: { bankid_pending: true },
}),
).toBe(true)
})
it('is true once the user has set a password themselves', () => {
expect(
hasForeignCredential({
identities: [identity('email')],
app_metadata: { bankid_pending: true, has_password: true },
}),
).toBe(true)
})
})
describe('isUnadoptedPendingAccount', () => {
it('is true only while the address is unconfirmed and no foreign credential exists', () => {
expect(
isUnadoptedPendingAccount({
identities: [identity('email')],
app_metadata: { bankid_pending: true, has_password: false },
email_confirmed_at: undefined,
}),
).toBe(true)
})
it('is false once the address was confirmed by any means', () => {
// A confirmed account is never deleted on re-signup, even without a
// foreign credential: the old flow admin-confirmed addresses it never
// proved, and those accounts may have data.
expect(
isUnadoptedPendingAccount({
identities: [identity('email')],
app_metadata: { bankid_linked: true },
email_confirmed_at: '2026-08-01T00:00:00Z',
}),
).toBe(false)
})
it('is false when a foreign credential exists even if unconfirmed', () => {
expect(
isUnadoptedPendingAccount({
identities: [identity('email')],
app_metadata: { has_password: true },
email_confirmed_at: undefined,
}),
).toBe(false)
})
})
describe('revokePendingIdentity', () => {
const updateUserById = vi.fn()
const calls: Array<{ method: string; args: unknown[] }> = []
let deleteResult: { error: { code?: string; message: string } | null }
function chain(): unknown {
return new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(deleteResult)
}
return (...args: unknown[]) => {
calls.push({ method: String(prop), args })
return chain()
}
},
},
)
}
const supabase = {
from: vi.fn(() => chain()),
auth: { admin: { updateUserById } },
} as unknown as SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
calls.length = 0
deleteResult = { error: null }
updateUserById.mockResolvedValue({ data: {}, error: null })
})
it('deletes only unverified rows for the user and drops bankid_pending, keeping the rest of app_metadata', async () => {
const ok = await revokePendingIdentity(supabase, 'user-1', {
bankid_pending: true,
has_password: true,
provider: 'email',
})
expect(ok).toBe(true)
expect(supabase.from).toHaveBeenCalledWith('bankid_identities')
expect(calls.map((c) => c.method)).toEqual(['delete', 'eq', 'is'])
expect(calls[1].args).toEqual(['user_id', 'user-1'])
expect(calls[2].args).toEqual(['email_verified_at', null])
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { bankid_pending: null, has_password: true, provider: 'email' },
})
// Never grants the MFA exemption on the way out.
const written = updateUserById.mock.calls[0][1].app_metadata as Record<string, unknown>
expect(written.bankid_linked).toBeUndefined()
})
it('returns false and leaves app_metadata alone when the delete fails', async () => {
deleteResult = { error: { code: 'XX000', message: 'boom' } }
const ok = await revokePendingIdentity(supabase, 'user-1', { bankid_pending: true })
expect(ok).toBe(false)
expect(updateUserById).not.toHaveBeenCalled()
})
it('tolerates missing prior metadata', async () => {
const ok = await revokePendingIdentity(supabase, 'user-1', undefined)
expect(ok).toBe(true)
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { bankid_pending: null },
})
})
})
+198 -43
View File
@@ -35,6 +35,12 @@ import {
setBankIdFlowCookies,
} from './lib/bankid-flow-cookie'
import { lookupCompanyByOrgNumber, registrationDateToMs } from './lib/lookup'
import {
hasForeignCredential,
isUnadoptedPendingAccount,
revokePendingIdentity,
} from './lib/bankid-pending'
import { sendBankIdSignupConfirmation } from './lib/bankid-confirmation-mail'
import { hashPersonalNumber, encryptPersonalNumberForStorage } from '@/lib/auth/bankid'
import {
evaluateBrandSignupGate,
@@ -43,11 +49,109 @@ import {
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'
import type { SupabaseClient, User } from '@supabase/supabase-js'
import crypto from 'crypto'
const log = createLogger('tic/bankid')
const SIGNUP_FAILED = { error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' }
/** Host the browser is on, as the proxy forwarded it. '' when unknown. */
function forwardedHost(request: Request): string {
return request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? ''
}
/**
* Login attempt by a BankID identity whose address was never proven
* (`email_verified_at IS NULL`, see lib/bankid-pending.ts). Never mints a
* session. Two outcomes:
*
* - The account has since been adopted through another credential (the real
* owner of the address set a password or signed in with Google): the
* pending link is revoked so it can never be promoted, and the BankID
* holder is told there is no account, which for them is now true.
* - Otherwise the account is still the unconfirmed shell the signup made:
* the confirmation mail is re-sent (best effort; every attempt costs a
* BankID identification, which bounds the volume) and the caller is asked
* to confirm first. The message surfaces in the login page, so Swedish.
*/
async function refusePendingLogin(
supabase: SupabaseClient,
userId: string,
user: User | null | undefined,
names: { givenName?: string; surname?: string },
request: Request,
): Promise<NextResponse> {
if (!user || hasForeignCredential(user)) {
if (user) {
log.warn('pending bankid identity revoked at login: account adopted by another credential', {
userId,
})
}
await revokePendingIdentity(supabase, userId, user?.app_metadata)
return NextResponse.json({ error: 'no_account', ...names }, { status: 404 })
}
if (user.email) {
// /auth/callback only looks for a pending identity when the flag is set.
// Rows created by the old flow (before the column existed) have the flag
// missing; heal it here so the re-sent mail can actually promote them.
if (user.app_metadata?.bankid_pending !== true) {
await supabase.auth.admin.updateUserById(userId, {
app_metadata: { ...(user.app_metadata ?? {}), bankid_pending: true },
})
}
const sent = await sendBankIdSignupConfirmation({
supabase,
email: user.email,
host: forwardedHost(request),
proto: request.headers.get('x-forwarded-proto'),
})
if (!sent.ok) {
log.warn('could not re-send bankid confirmation mail', { userId, step: sent.step })
}
}
return NextResponse.json(
{
error: 'email_unconfirmed',
message:
'Bekräfta din e-postadress först. Vi har skickat ett nytt bekräftelsemail till adressen du angav när kontot skapades.',
},
{ status: 403 }
)
}
/**
* The same BankID signs up again while an earlier signup is still pending
* (typo'd address, lost mail). Clear the stale link so this attempt can start
* over with the address typed now. The stale account is deleted outright only
* while it is still the unconfirmed shell the signup made (bankid_identities
* cascades); if somebody else has adopted it in the meantime, only the BankID
* link is removed and their account is left alone. False = could not clear.
*/
async function clearStalePendingSignup(supabase: SupabaseClient, userId: string): Promise<boolean> {
const { data } = await supabase.auth.admin.getUserById(userId)
const user = data?.user
if (user && isUnadoptedPendingAccount(user)) {
const { error } = await supabase.auth.admin.deleteUser(userId)
if (error) {
log.error('could not delete stale pending bankid signup', { userId, message: error.message })
return false
}
log.info('stale pending bankid signup deleted for re-signup', { userId })
return true
}
if (user) {
log.warn('pending bankid identity revoked at re-signup: account adopted by another credential', {
userId,
})
}
return revokePendingIdentity(supabase, userId, user?.app_metadata)
}
/**
* Claim a BankID session, atomically and exactly once.
*
@@ -1033,13 +1137,31 @@ export const ticExtension: Extension = {
const pnrHash = hashPersonalNumber(personalNumber)
const supabase = createServiceClient()
// Look up existing BankID identity
const { data: existing } = await supabase
// Look up existing BankID identity. email_verified_at NULL means the
// address on the account was never proven (pending signup): such an
// identity signs nobody in and is not "linked" for signup purposes.
const { data: existing, error: lookupError } = await supabase
.from('bankid_identities')
.select('user_id')
.select('user_id, email_verified_at')
.eq('personal_number_hash', pnrHash)
.single()
// PGRST116 is .single() finding no row, which is the normal "not
// linked" answer. Anything else (schema behind the code, connection
// lost) must not read as "no account": that would send every
// returning BankID user to signup. Not settled, so the completed
// identification survives a retry.
if (lookupError && lookupError.code !== 'PGRST116') {
log.error('bankid_identities lookup failed', {
code: lookupError.code,
message: lookupError.message,
})
return NextResponse.json(
{ error: 'service_unavailable', message: 'Tillfälligt fel. Försök igen om en stund.' },
{ status: 503 }
)
}
if (mode === 'login') {
if (!existing) {
// Terminal for a login flow: the user is sent to signup, which
@@ -1051,8 +1173,23 @@ export const ticExtension: Extension = {
}, { status: 404 }))
}
// Returning user: generate magic link
// Returning user. Load the account before deciding anything: a
// pending identity is refused (never a magic link), see
// refusePendingLogin.
const { data: userData } = await supabase.auth.admin.getUserById(existing.user_id)
if (existing.email_verified_at === null) {
return settle(
await refusePendingLogin(
supabase,
existing.user_id,
userData?.user,
{ givenName, surname },
request,
)
)
}
if (!userData?.user?.email) {
// Data problem, not a transient one: an identity with no user
// email will never complete. settle() so it is not re-offered as
@@ -1105,7 +1242,7 @@ export const ticExtension: Extension = {
}
// mode === 'signup'
if (existing) {
if (existing && existing.email_verified_at !== null) {
// Terminal: this BankID already has an account, so the answer is
// to sign in, not to retry this session.
return settle(NextResponse.json(
@@ -1114,6 +1251,17 @@ export const ticExtension: Extension = {
))
}
if (existing) {
// Pending identity from an earlier signup by this same BankID.
// Not settled: the identification is still good, only the stale
// link is in the way. A failure here is transient by nature.
if (!await clearStalePendingSignup(supabase, existing.user_id)) {
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
}
const host = forwardedHost(request)
// Invite-only brand domain gate (founder decision 2026-08-27):
// same rule POST /api/auth/signup enforces on the email path. Runs
// AFTER the existing-identity check so a returning user's login is
@@ -1121,10 +1269,7 @@ export const ticExtension: Extension = {
// deliberately NOT settled, so the visitor keeps the completed
// BankID identification if the byrå allowlists them mid-flow.
const gateResult = await evaluateBrandSignupGate({
host:
request.headers.get('x-forwarded-host') ??
request.headers.get('host') ??
'',
host,
email: trimmedEmail!,
inviteToken: readInviteTokenFromCookieHeader(request.headers.get('cookie')),
})
@@ -1154,10 +1299,18 @@ export const ticExtension: Extension = {
// 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.
//
// email_confirm: false. The address came from the request body and
// nothing has proven it belongs to the person holding the BankID.
// Confirming it here let anyone open an account on a stranger's
// address and keep a BankID login into it after the stranger adopted
// it (account pre-hijacking, security audit 2026-09). The address is
// confirmed by the mail sent below, and only then does the identity
// count (see lib/bankid-pending.ts).
const randomPassword = crypto.randomBytes(32).toString('base64url')
const { data: newUser, error: createError } = await supabase.auth.admin.createUser({
email: trimmedEmail!,
email_confirm: true,
email_confirm: false,
password: randomPassword,
user_metadata: { full_name: name },
})
@@ -1192,10 +1345,7 @@ export const ticExtension: Extension = {
code: createError?.code,
message: createError?.message,
})
return NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
)
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
const userId = newUser.user.id
@@ -1216,24 +1366,22 @@ export const ticExtension: Extension = {
}
}
// Mark user as BankID-linked (skips TOTP MFA) and record that they
// do not have a password yet: the BankID signup gave them a random
// server-side password they will never see. This flag gates MFA
// enrollment (see lib/auth/has-password.ts).
// Mark the BankID link as PENDING, not linked: bankid_linked (which
// skips TOTP MFA, lib/auth/mfa.ts) is set by /auth/callback once the
// confirmation mail is clicked. has_password: false records that the
// BankID signup gave them a random server-side password they will
// never see; it gates MFA enrollment (see lib/auth/has-password.ts).
const { error: metaError } = await supabase.auth.admin.updateUserById(userId, {
app_metadata: { bankid_linked: true, has_password: false },
app_metadata: { bankid_pending: true, has_password: false },
})
if (metaError) {
log.error('signup app_metadata update failed', { message: metaError.message, code: metaError.code })
await rollbackSignup('app_metadata update')
return NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
)
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
// Store BankID identity
// Store BankID identity, unverified until the mail is clicked.
const { error: insertError } = await supabase
.from('bankid_identities')
.insert({
@@ -1242,20 +1390,18 @@ export const ticExtension: Extension = {
personal_number_enc: encryptPersonalNumberForStorage(personalNumber),
given_name: givenName,
surname,
email_verified_at: null,
})
if (insertError) {
log.error('insert bankid_identities failed', { message: insertError.message, code: insertError.code })
await rollbackSignup('bankid_identities insert')
return NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
)
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
// Claim the session before minting. Placed after createUser so the
// Claim the session before mailing. Placed after createUser so the
// recoverable account_exists path above leaves the flow reusable,
// and before generateLink so two tabs cannot both mint.
// and before the mail so two tabs cannot both send one.
if (!await consumeBankIdSession(supabase, sessionId)) {
await rollbackSignup('session already consumed')
return settle(NextResponse.json(
@@ -1264,20 +1410,24 @@ export const ticExtension: Extension = {
))
}
// Generate magic link for session
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
type: 'magiclink',
// Mail the confirmation link to the typed address. The token never
// reaches the browser: whoever reads that inbox proves the address,
// and /auth/callback promotes the identity when they click.
const sent = await sendBankIdSignupConfirmation({
supabase,
email: trimmedEmail!,
host,
proto: request.headers.get('x-forwarded-proto'),
})
if (linkError || !link?.properties?.hashed_token) {
log.error('generateLink failed for signup', { message: linkError?.message, code: linkError?.code })
await rollbackSignup('generateLink')
if (!sent.ok) {
log.error('bankid signup confirmation mail failed', { step: sent.step, message: sent.message })
await rollbackSignup(`confirmation mail (${sent.step})`)
// The session was already consumed above, so this flow cannot be
// retried; settle() clears it rather than leaving a spent,
// rolled-back flow to be re-offered as resumable.
return settle(NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ error: 'internal_error', message: 'Kunde inte skicka bekräftelsemailet. Försök igen.' },
{ status: 500 }
))
}
@@ -1285,12 +1435,13 @@ export const ticExtension: Extension = {
// Enrichment (CompanyRoles): pre-fills /select-company picker.
await fetchAndStoreEnrichment(sessionId, userId, supabase)
// settle(): account created and magic link minted. The flow is spent.
// settle(): account created and the mail is out. The flow is spent.
// Same shape as POST /api/auth/signup, so the register page can show
// its existing "check your inbox" screen. No session, no token.
return settle(NextResponse.json({
data: {
tokenHash: link.properties.hashed_token,
type: 'magiclink',
isNewUser: true,
status: 'confirmation_sent',
email: trimmedEmail!,
},
}))
} catch (error) {
@@ -1444,7 +1595,10 @@ export const ticExtension: Extension = {
))
}
// Link BankID to current user
// Link BankID to current user. The caller is authenticated, so the
// address on the account is already theirs: verified from the start
// (the column has no default; NULL would make this a pending link
// that refuses to sign in).
const { error: insertError } = await supabase
.from('bankid_identities')
.insert({
@@ -1453,6 +1607,7 @@ export const ticExtension: Extension = {
personal_number_enc: encryptPersonalNumberForStorage(personalNumber),
given_name: givenName,
surname,
email_verified_at: new Date().toISOString(),
})
if (insertError) {
@@ -0,0 +1,99 @@
/**
* The confirmation mail a BankID signup sends to the typed address.
*
* The BankID signup used to mint a magic link and hand it straight back to the
* browser, which proved nothing about the address. Now the link travels only
* by mail, through the same token_hash + /auth/callback pattern the Supabase
* Send Email hook uses (app/api/auth/email-hook/route.ts), branded per the
* requesting host like every other auth mail. The browser never sees the
* token.
*
* `magiclink` rather than `signup` as the link type: GoTrue refuses a signup
* link for an already-confirmed address, and this mail is also re-sent when a
* pending identity tries to log in, which includes accounts created by the
* old flow (confirmed by admin, address never proven). Verifying a magic link
* confirms an unconfirmed address as a side effect, so both cases converge.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { buildAuthEmail } from '@/lib/email/auth-templates'
import { getSenderForBrand } from '@/lib/email/brand-sender'
import { getBranding } from '@/lib/branding/service'
import { resolveBrandByHost } from '@/lib/branding/resolve'
import { createLogger } from '@/lib/logger'
const log = createLogger('tic/bankid-confirmation-mail')
export interface SendBankIdConfirmationInput {
/** Service-role client (auth.admin.generateLink). */
supabase: SupabaseClient
/** Normalised (trimmed, lower-cased) recipient address. */
email: string
/** Forwarded host of the request, '' when unknown. */
host: string
/** Forwarded protocol of the request; defaults to https. */
proto?: string | null
}
export type SendBankIdConfirmationResult =
| { ok: true }
| { ok: false; step: 'generate_link' | 'send'; message?: string }
/**
* Confirmation links must land on the ORIGINATING host (the brand mail
* resolves its brand from it), mirroring POST /api/auth/signup. With no host
* (direct invocation, tests) the canonical app URL is used.
*/
export function buildConfirmationUrl(
host: string,
proto: string | null | undefined,
tokenHash: string,
): string {
const base = host ? `${proto || 'https'}://${host}` : getBranding().appUrl
const url = new URL('/auth/callback', base)
url.searchParams.set('token_hash', tokenHash)
url.searchParams.set('type', 'magiclink')
return url.toString()
}
export async function sendBankIdSignupConfirmation(
input: SendBankIdConfirmationInput,
): Promise<SendBankIdConfirmationResult> {
const { data: link, error: linkError } = await input.supabase.auth.admin.generateLink({
type: 'magiclink',
email: input.email,
})
if (linkError || !link?.properties?.hashed_token) {
log.error('generateLink failed for bankid confirmation mail', {
code: linkError?.code,
message: linkError?.message,
})
return { ok: false, step: 'generate_link', message: linkError?.message }
}
const brand = input.host ? await resolveBrandByHost(input.host) : null
const sender = getSenderForBrand(brand)
const appName = brand?.appName ?? getBranding().appName
const mail = buildAuthEmail({
actionType: 'bankid_signup',
appName,
actionUrl: buildConfirmationUrl(input.host, input.proto, link.properties.hashed_token),
})
const result = await getEmailService().sendEmail({
to: input.email,
subject: mail.subject,
html: mail.html,
text: mail.text,
fromName: sender.fromName ?? undefined,
fromAddress: sender.fromAddress ?? undefined,
replyTo: sender.replyTo ?? undefined,
})
if (!result.success) {
log.error('bankid confirmation mail send failed', new Error(result.error ?? 'unknown'))
return { ok: false, step: 'send', message: result.error }
}
return { ok: true }
}
@@ -0,0 +1,90 @@
/**
* Pending BankID identities (security audit 2026-09, account pre-hijacking).
*
* A BankID signup creates the auth user for whatever address the caller typed.
* Until that address is proven (the confirmation mail is clicked and
* /auth/callback promotes the row), the `bankid_identities` row carries
* `email_verified_at IS NULL` and the auth user carries
* `app_metadata.bankid_pending: true` instead of `bankid_linked: true`. A
* pending identity must never sign anyone in, and it must never be promoted
* once the account has been adopted through another credential (the real
* owner of the address set a password or signed in with Google).
*
* The promotion side lives in app/(auth)/auth/callback/route.ts (core, so it
* cannot import this module); `hasForeignCredential` there mirrors the one
* here on purpose. Keep the two in step.
*/
import type { SupabaseClient, User } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('tic/bankid-pending')
/** The subset of the auth user the adoption checks read. */
export type AdoptionUser = Pick<User, 'identities' | 'app_metadata' | 'email_confirmed_at'>
/**
* True when the account has a credential the BankID signup never created:
* a non-email identity (Google) or a password the user set themselves
* (`has_password: true`, written only by POST /api/account/password). Either
* one means somebody proved ownership of the address by other means, and the
* pending BankID identity must be revoked rather than promoted.
*/
export function hasForeignCredential(user: AdoptionUser): boolean {
const identities = user.identities ?? []
if (identities.some((identity) => identity.provider !== 'email')) return true
return user.app_metadata?.has_password === true
}
/**
* True when the pending account is still exactly what the BankID signup made:
* address unconfirmed, no foreign credential. Only such an account may be
* deleted outright when the same BankID signs up again (typo'd address, lost
* mail): nobody else has a stake in it yet.
*/
export function isUnadoptedPendingAccount(user: AdoptionUser): boolean {
return !user.email_confirmed_at && !hasForeignCredential(user)
}
/**
* Remove the pending BankID link from an account that somebody else now owns:
* delete the unverified identity row(s) and drop `bankid_pending` from
* app_metadata. `bankid_linked` is untouched (a pending signup never set it).
* Read-merge-write on app_metadata, like every other writer in this
* extension. Returns false when the row delete failed; the flag update is
* best-effort.
*/
export async function revokePendingIdentity(
supabase: SupabaseClient,
userId: string,
priorAppMetadata: Record<string, unknown> | undefined,
): Promise<boolean> {
const { error: deleteError } = await supabase
.from('bankid_identities')
.delete()
.eq('user_id', userId)
.is('email_verified_at', null)
if (deleteError) {
log.error('could not revoke pending bankid identity', {
userId,
code: deleteError.code,
message: deleteError.message,
})
return false
}
const { error: metaError } = await supabase.auth.admin.updateUserById(userId, {
// null removes the key under GoTrue's merge semantics and is falsy if the
// metadata is ever replaced wholesale instead.
app_metadata: { ...(priorAppMetadata ?? {}), bankid_pending: null },
})
if (metaError) {
log.error('could not clear bankid_pending after revoking identity', {
userId,
code: metaError.code,
message: metaError.message,
})
}
return true
}