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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6486e0d9e2
commit
6d75b9a1bf
@@ -0,0 +1,95 @@
|
||||
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('****')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { isMfaRequired, shouldEnforceMfa } from '../mfa'
|
||||
|
||||
describe('mfa helpers', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('isMfaRequired', () => {
|
||||
it('returns false when self-hosted', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(isMfaRequired()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when hosted and MFA required', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(isMfaRequired()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldEnforceMfa', () => {
|
||||
it('returns false when MFA is not required', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
expect(shouldEnforceMfa({ app_metadata: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when user has bankid_linked', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(shouldEnforceMfa({ app_metadata: { bankid_linked: true } })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when MFA required and no bankid', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(shouldEnforceMfa({ app_metadata: {} })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when app_metadata is undefined', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
expect(shouldEnforceMfa({})).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* BankID authentication helpers.
|
||||
*
|
||||
* BankID is only available on the hosted deployment (requires TIC Identity API).
|
||||
* Self-hosted deployments never show the BankID option.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto'
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feature flag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isBankIdEnabled(): boolean {
|
||||
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
|
||||
return process.env.NEXT_PUBLIC_BANKID_ENABLED === 'true'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personnummer hashing (for lookup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** SHA-256 hash of a personnummer for fast DB lookup. */
|
||||
export function hashPersonalNumber(personalNumber: string): string {
|
||||
return crypto.createHash('sha256').update(personalNumber).digest('hex')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personnummer encryption (for display in settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const key = process.env.BANKID_ENCRYPTION_KEY
|
||||
if (!key) throw new Error('BANKID_ENCRYPTION_KEY is required for BankID operations')
|
||||
return Buffer.from(key, 'hex')
|
||||
}
|
||||
|
||||
/** AES-256-GCM encrypt a personnummer for storage. */
|
||||
export function encryptPersonalNumber(personalNumber: string): Buffer {
|
||||
const key = getEncryptionKey()
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(personalNumber, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
|
||||
// Format: iv (12) + tag (16) + ciphertext
|
||||
return Buffer.concat([iv, tag, encrypted])
|
||||
}
|
||||
|
||||
/** AES-256-GCM decrypt a stored personnummer. */
|
||||
export function decryptPersonalNumber(data: Buffer): string {
|
||||
const key = getEncryptionKey()
|
||||
|
||||
const iv = data.subarray(0, 12)
|
||||
const tag = data.subarray(12, 28)
|
||||
const encrypted = data.subarray(28)
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mask a personnummer for display: "XXXXXXXX-1234" */
|
||||
export function maskPersonalNumber(personalNumber: string): string {
|
||||
if (personalNumber.length < 4) return '****'
|
||||
const last4 = personalNumber.slice(-4)
|
||||
const masked = personalNumber.length === 12 ? 'XXXXXXXX' : 'XXXXXX'
|
||||
return `${masked}-${last4}`
|
||||
}
|
||||
@@ -9,3 +9,13 @@ export function isMfaRequired(): boolean {
|
||||
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
|
||||
return process.env.NEXT_PUBLIC_REQUIRE_MFA === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MFA should be enforced for a specific user.
|
||||
* BankID-linked users skip TOTP because BankID is inherently 2FA.
|
||||
*/
|
||||
export function shouldEnforceMfa(user: { app_metadata?: Record<string, unknown> }): boolean {
|
||||
if (!isMfaRequired()) return false
|
||||
if (user.app_metadata?.bankid_linked) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isMfaRequired } from './mfa'
|
||||
import { shouldEnforceMfa } from './mfa'
|
||||
import type { User, SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
type AuthResult =
|
||||
@@ -25,7 +25,7 @@ export async function requireAuth(): Promise<AuthResult> {
|
||||
}
|
||||
}
|
||||
|
||||
if (isMfaRequired()) {
|
||||
if (shouldEnforceMfa(user)) {
|
||||
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||
if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user