Files
accounted/lib/auth/__tests__/bankid.test.ts
T
Jakob WennbergandClaude Opus 4.6 6d75b9a1bf feat: BankID authentication via TIC Identity API (#192)
* feat: add BankID authentication via TIC Identity API

Integrate BankID as a login/signup method using the TIC Identity API.
Users can authenticate with BankID QR codes (desktop) or deep links (mobile),
link BankID to existing accounts, and skip TOTP MFA when BankID is linked.
Removes Step 0 (role choice) from onboarding for all users. Adds enrichment
data support for pre-filling company details from Bolagsverket during signup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — server-side rate limit, unlink clears MFA bypass

- Add per-IP rate limit (5s cooldown) on /bankid/start to prevent
  unbounded billable TIC sessions from unauthenticated callers
- Add /bankid/unlink endpoint that deletes bankid_identities AND clears
  app_metadata.bankid_linked so MFA enforcement resumes after unlink
- Update BankIdSettings to call server-side unlink instead of client-side delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move rate limiter to module scope, add BankID logo and year-end skill

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:44:14 +02:00

96 lines
3.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
isBankIdEnabled,
hashPersonalNumber,
encryptPersonalNumber,
decryptPersonalNumber,
maskPersonalNumber,
} from '../bankid'
// Generate a valid 32-byte hex key for tests
const TEST_KEY = 'a'.repeat(64) // 32 bytes in hex
describe('bankid helpers', () => {
beforeEach(() => {
vi.stubEnv('BANKID_ENCRYPTION_KEY', TEST_KEY)
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('isBankIdEnabled', () => {
it('returns false when NEXT_PUBLIC_SELF_HOSTED is true', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
vi.stubEnv('NEXT_PUBLIC_BANKID_ENABLED', 'true')
expect(isBankIdEnabled()).toBe(false)
})
it('returns true when BANKID_ENABLED is true and not self-hosted', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_BANKID_ENABLED', 'true')
expect(isBankIdEnabled()).toBe(true)
})
it('returns false when BANKID_ENABLED is not set', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_BANKID_ENABLED', '')
expect(isBankIdEnabled()).toBe(false)
})
})
describe('hashPersonalNumber', () => {
it('returns a consistent SHA-256 hex hash', () => {
const hash1 = hashPersonalNumber('199001011234')
const hash2 = hashPersonalNumber('199001011234')
expect(hash1).toBe(hash2)
expect(hash1).toMatch(/^[a-f0-9]{64}$/)
})
it('returns different hashes for different numbers', () => {
const hash1 = hashPersonalNumber('199001011234')
const hash2 = hashPersonalNumber('199001015678')
expect(hash1).not.toBe(hash2)
})
})
describe('encrypt/decrypt round-trip', () => {
it('encrypts and decrypts a personnummer', () => {
const pnr = '199001011234'
const encrypted = encryptPersonalNumber(pnr)
expect(encrypted).toBeInstanceOf(Buffer)
// iv (12) + tag (16) + ciphertext (at least 1 byte)
expect(encrypted.length).toBeGreaterThan(28)
const decrypted = decryptPersonalNumber(encrypted)
expect(decrypted).toBe(pnr)
})
it('produces different ciphertext each time (random IV)', () => {
const pnr = '199001011234'
const enc1 = encryptPersonalNumber(pnr)
const enc2 = encryptPersonalNumber(pnr)
expect(enc1.equals(enc2)).toBe(false)
})
it('throws when BANKID_ENCRYPTION_KEY is missing', () => {
vi.stubEnv('BANKID_ENCRYPTION_KEY', '')
expect(() => encryptPersonalNumber('199001011234')).toThrow('BANKID_ENCRYPTION_KEY')
})
})
describe('maskPersonalNumber', () => {
it('masks a 12-digit personnummer', () => {
expect(maskPersonalNumber('199001011234')).toBe('XXXXXXXX-1234')
})
it('masks a 10-digit personnummer', () => {
expect(maskPersonalNumber('9001011234')).toBe('XXXXXX-1234')
})
it('handles short input gracefully', () => {
expect(maskPersonalNumber('12')).toBe('****')
})
})
})