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,176 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
startBankIdAuth,
|
||||
pollBankIdSession,
|
||||
collectBankIdResult,
|
||||
cancelBankIdSession,
|
||||
} from '../lib/bankid-client'
|
||||
import { TICAPIError } from '../lib/tic-types'
|
||||
|
||||
const API_KEY = 'test-api-key'
|
||||
const BASE_URL = 'https://id.tic.io/api/v1'
|
||||
|
||||
describe('bankid-client', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
vi.stubEnv('TIC_IDENTITY_API_KEY', API_KEY)
|
||||
vi.stubEnv('TIC_IDENTITY_API_URL', BASE_URL)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('startBankIdAuth', () => {
|
||||
it('calls POST /auth/bankid/start with correct params', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
autoStartToken: 'auto-token',
|
||||
qrStartToken: 'qr-token',
|
||||
qrStartSecret: 'qr-secret',
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await startBankIdAuth('192.168.1.1', 'TestAgent')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/auth/bankid/start`,
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'X-Api-Key': API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const body = JSON.parse(
|
||||
(vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string
|
||||
)
|
||||
expect(body.endUserIp).toBe('192.168.1.1')
|
||||
expect(body.userAgent).toBe('TestAgent')
|
||||
expect(result.sessionId).toBe('test-session')
|
||||
})
|
||||
|
||||
it('throws NOT_CONFIGURED when API key is missing', async () => {
|
||||
vi.stubEnv('TIC_IDENTITY_API_KEY', '')
|
||||
|
||||
await expect(startBankIdAuth('1.1.1.1')).rejects.toThrow(TICAPIError)
|
||||
await expect(startBankIdAuth('1.1.1.1')).rejects.toMatchObject({
|
||||
code: 'NOT_CONFIGURED',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws RATE_LIMIT_EXCEEDED on 429', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response('Too many requests', { status: 429 })
|
||||
)
|
||||
|
||||
await expect(startBankIdAuth('1.1.1.1')).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pollBankIdSession', () => {
|
||||
it('calls POST /auth/{sessionId}/poll', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
status: 'pending',
|
||||
hintCode: 'outstandingTransaction',
|
||||
message: 'Starta BankID-appen',
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await pollBankIdSession('test-session')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/auth/test-session/poll`,
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
expect(result.status).toBe('pending')
|
||||
expect(result.message).toBe('Starta BankID-appen')
|
||||
})
|
||||
|
||||
it('returns complete status with user data', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
status: 'complete',
|
||||
user: {
|
||||
personalNumber: '199001011234',
|
||||
givenName: 'Test',
|
||||
surname: 'Testsson',
|
||||
name: 'Test Testsson',
|
||||
},
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await pollBankIdSession('test-session')
|
||||
expect(result.status).toBe('complete')
|
||||
expect(result.user?.personalNumber).toBe('199001011234')
|
||||
})
|
||||
|
||||
it('returns failed status on 410 Gone', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
status: 'failed',
|
||||
hintCode: 'userCancel',
|
||||
error: 'authentication_failed',
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 410 })
|
||||
)
|
||||
|
||||
const result = await pollBankIdSession('test-session')
|
||||
expect(result.status).toBe('failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectBankIdResult', () => {
|
||||
it('calls GET /auth/{sessionId}/collect', async () => {
|
||||
const mockResponse = {
|
||||
sessionId: 'test-session',
|
||||
status: 'complete',
|
||||
user: {
|
||||
personalNumber: '199001011234',
|
||||
givenName: 'Test',
|
||||
surname: 'Testsson',
|
||||
name: 'Test Testsson',
|
||||
},
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), { status: 200 })
|
||||
)
|
||||
|
||||
const result = await collectBankIdResult('test-session')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/auth/test-session/collect`,
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
)
|
||||
expect(result.user?.name).toBe('Test Testsson')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelBankIdSession', () => {
|
||||
it('calls DELETE /auth/{sessionId}', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 }))
|
||||
|
||||
await cancelBankIdSession('test-session')
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/auth/test-session`,
|
||||
expect.objectContaining({ method: 'DELETE' })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,9 +9,25 @@ import {
|
||||
getCompanyPurpose,
|
||||
getFinancialReportSummaries,
|
||||
} from './lib/tic-client'
|
||||
import {
|
||||
startBankIdAuth,
|
||||
pollBankIdSession,
|
||||
collectBankIdResult,
|
||||
cancelBankIdSession,
|
||||
requestEnrichment,
|
||||
fetchEnrichmentData,
|
||||
} from './lib/bankid-client'
|
||||
import { TICAPIError } from './lib/tic-types'
|
||||
import type { TICCompanyProfile } from './lib/tic-types'
|
||||
import type { BankIdCompleteRequest } from './lib/bankid-types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import crypto from 'crypto'
|
||||
|
||||
// Server-side per-IP rate limit for /bankid/start (each call = billable TIC session)
|
||||
const bankIdStartCooldowns = new Map<string, number>()
|
||||
const BANKID_START_COOLDOWN_MS = 5_000
|
||||
|
||||
/** Map TIC bankAccountType enum to human-readable string */
|
||||
function bankAccountTypeLabel(type?: number): string {
|
||||
@@ -340,6 +356,421 @@ export const ticExtension: Extension = {
|
||||
}
|
||||
},
|
||||
},
|
||||
// ── BankID Authentication ──────────────────────────────────────
|
||||
// Routes for BankID login/signup via TIC Identity API.
|
||||
// skipAuth: true on auth routes (user has no Supabase session yet).
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/start',
|
||||
skipAuth: true,
|
||||
handler: async (request: Request) => {
|
||||
try {
|
||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| request.headers.get('x-real-ip')
|
||||
|| '127.0.0.1'
|
||||
|
||||
// Per-IP rate limit (each start = billable TIC session)
|
||||
const now = Date.now()
|
||||
const lastStart = bankIdStartCooldowns.get(ip) ?? 0
|
||||
if (now - lastStart < BANKID_START_COOLDOWN_MS) {
|
||||
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
|
||||
}
|
||||
bankIdStartCooldowns.set(ip, now)
|
||||
|
||||
// Prevent map from growing unbounded
|
||||
if (bankIdStartCooldowns.size > 10_000) {
|
||||
const cutoff = now - BANKID_START_COOLDOWN_MS
|
||||
for (const [k, v] of bankIdStartCooldowns) {
|
||||
if (v < cutoff) bankIdStartCooldowns.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get('user-agent') || undefined
|
||||
|
||||
const session = await startBankIdAuth(ip, userAgent)
|
||||
return NextResponse.json({ data: session })
|
||||
} catch (error) {
|
||||
if (error instanceof TICAPIError) {
|
||||
if (error.code === 'NOT_CONFIGURED') {
|
||||
return NextResponse.json({ error: 'BankID is not configured' }, { status: 503 })
|
||||
}
|
||||
if (error.code === 'RATE_LIMIT_EXCEEDED') {
|
||||
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
||||
}
|
||||
}
|
||||
console.error('[tic/bankid] start failed', error)
|
||||
return NextResponse.json({ error: 'Failed to start BankID session' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/poll',
|
||||
skipAuth: true,
|
||||
handler: async (request: Request) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const sessionId = body?.sessionId
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await pollBankIdSession(sessionId)
|
||||
if (result.status !== 'pending') {
|
||||
console.log('[tic/bankid] poll status:', result.status, result.hintCode, result.user?.personalNumber ? 'has-user' : 'no-user')
|
||||
}
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
if (error instanceof TICAPIError) {
|
||||
if (error.code === 'RATE_LIMIT_EXCEEDED') {
|
||||
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
||||
}
|
||||
}
|
||||
console.error('[tic/bankid] poll failed', error)
|
||||
return NextResponse.json({ error: 'Failed to poll BankID session' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/complete',
|
||||
skipAuth: true,
|
||||
handler: async (request: Request) => {
|
||||
try {
|
||||
const body: BankIdCompleteRequest = await request.json()
|
||||
const { sessionId, mode, email } = body
|
||||
|
||||
if (!sessionId || !mode) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sessionId and mode are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'signup' && !email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'email is required for signup' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify BankID session is complete
|
||||
const session = await collectBankIdResult(sessionId)
|
||||
if (session.status !== 'complete' || !session.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'session_invalid', message: 'BankID session is not complete' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { personalNumber, givenName, surname, name } = session.user
|
||||
const pnrHash = hashPersonalNumber(personalNumber)
|
||||
const supabase = createServiceClientNoCookies()
|
||||
|
||||
// Look up existing BankID identity
|
||||
const { data: existing } = await supabase
|
||||
.from('bankid_identities')
|
||||
.select('user_id')
|
||||
.eq('personal_number_hash', pnrHash)
|
||||
.single()
|
||||
|
||||
if (mode === 'login') {
|
||||
if (!existing) {
|
||||
return NextResponse.json({
|
||||
error: 'no_account',
|
||||
givenName,
|
||||
surname,
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
// Returning user — generate magic link
|
||||
const { data: userData } = await supabase.auth.admin.getUserById(existing.user_id)
|
||||
if (!userData?.user?.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'session_invalid', message: 'User account not found' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email: userData.user.email,
|
||||
})
|
||||
|
||||
if (linkError || !link?.properties?.hashed_token) {
|
||||
console.error('[tic/bankid] generateLink failed', linkError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
tokenHash: link.properties.hashed_token,
|
||||
type: 'magiclink',
|
||||
isNewUser: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// mode === 'signup'
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'already_linked', message: 'This BankID is already linked to an account' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create new Supabase user
|
||||
const randomPassword = crypto.randomBytes(32).toString('base64url')
|
||||
const { data: newUser, error: createError } = await supabase.auth.admin.createUser({
|
||||
email: email!,
|
||||
email_confirm: true,
|
||||
password: randomPassword,
|
||||
user_metadata: { full_name: name },
|
||||
})
|
||||
|
||||
if (createError || !newUser?.user) {
|
||||
console.error('[tic/bankid] createUser failed', createError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create account', message: createError?.message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = newUser.user.id
|
||||
|
||||
// Mark user as BankID-linked (skips TOTP MFA)
|
||||
await supabase.auth.admin.updateUserById(userId, {
|
||||
app_metadata: { bankid_linked: true },
|
||||
})
|
||||
|
||||
// Store BankID identity
|
||||
const { error: insertError } = await supabase
|
||||
.from('bankid_identities')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
personal_number_hash: pnrHash,
|
||||
personal_number_enc: encryptPersonalNumber(personalNumber),
|
||||
given_name: givenName,
|
||||
surname,
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
console.error('[tic/bankid] insert bankid_identities failed', insertError)
|
||||
// Clean up the created user if identity linking fails
|
||||
await supabase.auth.admin.deleteUser(userId)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to link BankID identity' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate magic link for session
|
||||
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email: email!,
|
||||
})
|
||||
|
||||
if (linkError || !link?.properties?.hashed_token) {
|
||||
console.error('[tic/bankid] generateLink failed for new user', linkError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Account created but failed to create session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Attempt enrichment and store for onboarding pre-fill
|
||||
try {
|
||||
const enrichment = await requestEnrichment(sessionId, ['SPAR', 'CompanyRoles'])
|
||||
if (enrichment.status === 'Completed' && enrichment.secureUrl) {
|
||||
const enrichmentData = await fetchEnrichmentData(enrichment.secureUrl)
|
||||
console.log('[tic/bankid] enrichment success', {
|
||||
hasSpar: !!enrichmentData.spar,
|
||||
companyCount: enrichmentData.companyRoles?.length ?? 0,
|
||||
})
|
||||
|
||||
// Store enrichment data in extension_data for the onboarding page to read
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
extension_id: 'tic',
|
||||
key: 'bankid_enrichment',
|
||||
value: enrichmentData,
|
||||
}, { onConflict: 'user_id,extension_id,key' })
|
||||
}
|
||||
} catch (enrichError) {
|
||||
// Enrichment is optional — don't fail signup
|
||||
console.warn('[tic/bankid] enrichment failed (non-blocking)', enrichError)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
tokenHash: link.properties.hashed_token,
|
||||
type: 'magiclink',
|
||||
isNewUser: true,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof TICAPIError) {
|
||||
console.error('[tic/bankid] complete TIC error', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'BankID verification failed' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
console.error('[tic/bankid] complete unexpected error', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to complete BankID authentication' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/bankid/:sessionId',
|
||||
skipAuth: true,
|
||||
handler: async (request: Request) => {
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const sessionId = url.searchParams.get('_sessionId')
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
await cancelBankIdSession(sessionId)
|
||||
return NextResponse.json({ data: { cancelled: true } })
|
||||
} catch (error) {
|
||||
console.error('[tic/bankid] cancel failed', error)
|
||||
return NextResponse.json({ error: 'Failed to cancel session' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/link',
|
||||
// skipAuth: false — requires existing Supabase session
|
||||
handler: async (request: Request, ctx?) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { sessionId } = body
|
||||
|
||||
if (!sessionId || !ctx?.userId) {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify BankID session
|
||||
const session = await collectBankIdResult(sessionId)
|
||||
if (session.status !== 'complete' || !session.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'session_invalid', message: 'BankID session is not complete' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { personalNumber, givenName, surname } = session.user
|
||||
const pnrHash = hashPersonalNumber(personalNumber)
|
||||
const supabase = createServiceClientNoCookies()
|
||||
|
||||
// Check personnummer not already linked to another user
|
||||
const { data: existing } = await supabase
|
||||
.from('bankid_identities')
|
||||
.select('user_id')
|
||||
.eq('personal_number_hash', pnrHash)
|
||||
.single()
|
||||
|
||||
if (existing && existing.user_id !== ctx.userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'already_linked', message: 'This BankID is already linked to another account' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existing && existing.user_id === ctx.userId) {
|
||||
return NextResponse.json({ data: { linked: true, alreadyLinked: true } })
|
||||
}
|
||||
|
||||
// Link BankID to current user
|
||||
const { error: insertError } = await supabase
|
||||
.from('bankid_identities')
|
||||
.insert({
|
||||
user_id: ctx.userId,
|
||||
personal_number_hash: pnrHash,
|
||||
personal_number_enc: encryptPersonalNumber(personalNumber),
|
||||
given_name: givenName,
|
||||
surname,
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
console.error('[tic/bankid] link insert failed', insertError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to link BankID' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Mark user as BankID-linked (skips TOTP MFA)
|
||||
await supabase.auth.admin.updateUserById(ctx.userId, {
|
||||
app_metadata: { bankid_linked: true },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { linked: true } })
|
||||
} catch (error) {
|
||||
console.error('[tic/bankid] link failed', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to link BankID' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/bankid/unlink',
|
||||
// skipAuth: false — requires existing Supabase session
|
||||
handler: async (_request: Request, ctx?) => {
|
||||
try {
|
||||
if (!ctx?.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const supabase = createServiceClientNoCookies()
|
||||
|
||||
// Delete bankid_identities row
|
||||
const { error: deleteError } = await supabase
|
||||
.from('bankid_identities')
|
||||
.delete()
|
||||
.eq('user_id', ctx.userId)
|
||||
|
||||
if (deleteError) {
|
||||
console.error('[tic/bankid] unlink delete failed', deleteError)
|
||||
return NextResponse.json({ error: 'Failed to unlink BankID' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Clear app_metadata.bankid_linked so MFA enforcement resumes
|
||||
await supabase.auth.admin.updateUserById(ctx.userId, {
|
||||
app_metadata: { bankid_linked: false },
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { unlinked: true } })
|
||||
} catch (error) {
|
||||
console.error('[tic/bankid] unlink failed', error)
|
||||
return NextResponse.json({ error: 'Failed to unlink BankID' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
eventHandlers: [],
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* TIC Identity API client for BankID authentication.
|
||||
*
|
||||
* Direct calls to https://id.tic.io/api/v1/ with X-Api-Key header.
|
||||
* This is a separate API surface from the company lookup proxy (TIC_API_PROXY_URL).
|
||||
*/
|
||||
|
||||
import { TICAPIError } from './tic-types'
|
||||
import type {
|
||||
BankIdStartRequest,
|
||||
BankIdStartResponse,
|
||||
BankIdPollResponse,
|
||||
BankIdCollectResponse,
|
||||
EnrichmentRequest,
|
||||
EnrichmentResponse,
|
||||
EnrichmentData,
|
||||
} from './bankid-types'
|
||||
|
||||
const API_TIMEOUT = 15_000
|
||||
|
||||
function getConfig(): { baseUrl: string; apiKey: string } {
|
||||
const baseUrl = process.env.TIC_IDENTITY_API_URL || 'https://id.tic.io/api/v1'
|
||||
const apiKey = process.env.TIC_IDENTITY_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new TICAPIError('TIC_IDENTITY_API_KEY is not configured', undefined, 'NOT_CONFIGURED')
|
||||
}
|
||||
return { baseUrl, apiKey }
|
||||
}
|
||||
|
||||
async function identityFetch<T>(
|
||||
method: 'GET' | 'POST' | 'DELETE',
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const { baseUrl, apiKey } = getConfig()
|
||||
const url = `${baseUrl}${path}`
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-Api-Key': apiKey,
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(API_TIMEOUT),
|
||||
})
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new TICAPIError('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED')
|
||||
}
|
||||
|
||||
if (response.status === 410) {
|
||||
// Session failed/expired — return the error body
|
||||
const data = await response.json()
|
||||
return data as T
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => response.statusText)
|
||||
throw new TICAPIError(`TIC Identity API error: ${errorBody}`, response.status)
|
||||
}
|
||||
|
||||
// DELETE returns no body
|
||||
if (response.status === 204 || method === 'DELETE') {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TICAPIError) throw error
|
||||
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
|
||||
throw new TICAPIError('Request timeout', undefined, 'TIMEOUT')
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
throw new TICAPIError(`TIC Identity API request failed: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Start a new BankID authentication session. */
|
||||
export async function startBankIdAuth(
|
||||
endUserIp: string,
|
||||
userAgent?: string
|
||||
): Promise<BankIdStartResponse> {
|
||||
const body: BankIdStartRequest = { endUserIp }
|
||||
if (userAgent) body.userAgent = userAgent
|
||||
|
||||
return identityFetch<BankIdStartResponse>('POST', '/auth/bankid/start', body)
|
||||
}
|
||||
|
||||
/** Poll a BankID session for status updates. Call every 2 seconds. */
|
||||
export async function pollBankIdSession(sessionId: string): Promise<BankIdPollResponse> {
|
||||
return identityFetch<BankIdPollResponse>('POST', `/auth/${sessionId}/poll`)
|
||||
}
|
||||
|
||||
/** Fetch cached session data (after webhook/callback, not for polling). */
|
||||
export async function collectBankIdResult(sessionId: string): Promise<BankIdCollectResponse> {
|
||||
return identityFetch<BankIdCollectResponse>('GET', `/auth/${sessionId}/collect`)
|
||||
}
|
||||
|
||||
/** Cancel an active BankID session. */
|
||||
export async function cancelBankIdSession(sessionId: string): Promise<void> {
|
||||
return identityFetch<void>('DELETE', `/auth/${sessionId}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enrichment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Request enrichment data for a completed session. Must be called within 30 min. */
|
||||
export async function requestEnrichment(
|
||||
sessionId: string,
|
||||
types: EnrichmentRequest['types']
|
||||
): Promise<EnrichmentResponse> {
|
||||
const body: EnrichmentRequest = { sessionId, types }
|
||||
return identityFetch<EnrichmentResponse>('POST', '/enrichment', body)
|
||||
}
|
||||
|
||||
/** Fetch enrichment data from the one-time secure URL. No API key needed for this call. */
|
||||
export async function fetchEnrichmentData(secureUrl: string): Promise<EnrichmentData> {
|
||||
const { baseUrl } = getConfig()
|
||||
// secureUrl is a relative path like /api/v1/enrichment/data/{token}
|
||||
const url = secureUrl.startsWith('http') ? secureUrl : `${baseUrl.replace('/api/v1', '')}${secureUrl}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(API_TIMEOUT),
|
||||
})
|
||||
|
||||
if (response.status === 410) {
|
||||
throw new TICAPIError('Enrichment data expired or already fetched', 410, 'TOKEN_EXPIRED')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TICAPIError(`Failed to fetch enrichment data: ${response.statusText}`, response.status)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* TIC Identity API types for BankID authentication.
|
||||
* API reference: https://id.tic.io/api/v1
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BankIdStartRequest {
|
||||
endUserIp: string
|
||||
userAgent?: string
|
||||
personalNumber?: string
|
||||
callbackUrl?: string
|
||||
webhookUrl?: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface BankIdStartResponse {
|
||||
sessionId: string
|
||||
provider: string
|
||||
orderRef: string
|
||||
autoStartToken: string
|
||||
qrStartToken: string
|
||||
qrStartSecret: string
|
||||
subscriptionToken: string
|
||||
sessionExpiresAt: string
|
||||
endUserIp: string
|
||||
}
|
||||
|
||||
export interface BankIdUser {
|
||||
personalNumber: string
|
||||
givenName: string
|
||||
surname: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface BankIdPollResponse {
|
||||
sessionId: string
|
||||
status: 'pending' | 'complete' | 'failed' | 'cancelled'
|
||||
hintCode?: string
|
||||
message?: string
|
||||
messageEn?: string
|
||||
orderCount?: number
|
||||
maxOrders?: number
|
||||
sessionExpiresInSeconds?: number
|
||||
/** Present when status is 'complete' */
|
||||
user?: BankIdUser
|
||||
completedAt?: string
|
||||
/** Updated tokens on order regeneration (~25s) */
|
||||
qrStartToken?: string
|
||||
qrStartSecret?: string
|
||||
/** Present when status is 'failed' */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BankIdCollectResponse {
|
||||
sessionId: string
|
||||
status: 'pending' | 'complete' | 'failed' | 'cancelled'
|
||||
hintCode?: string
|
||||
user?: BankIdUser
|
||||
completedAt?: string
|
||||
orderCount?: number
|
||||
maxOrders?: number
|
||||
sessionExpiresInSeconds?: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enrichment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EnrichmentType =
|
||||
| 'SPAR'
|
||||
| 'CompanyRoles'
|
||||
| 'PropertyOwnership'
|
||||
| 'Income'
|
||||
| 'IpIntelligence'
|
||||
| 'Full'
|
||||
|
||||
export interface EnrichmentRequest {
|
||||
sessionId: string
|
||||
types: EnrichmentType[]
|
||||
webhookUrl?: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
export interface EnrichmentResponse {
|
||||
enrichmentId: string
|
||||
sessionId: string
|
||||
status: 'Pending' | 'Processing' | 'Completed' | 'PartiallyCompleted' | 'Failed'
|
||||
requestedTypes: EnrichmentType[]
|
||||
completedTypes: EnrichmentType[]
|
||||
secureUrl: string
|
||||
secureUrlExpiresAtUtc: string
|
||||
}
|
||||
|
||||
export interface SparData {
|
||||
Person_IdNummer: string
|
||||
Person_PersonIdTyp: string
|
||||
Skydd_Sekretessmarkering: boolean
|
||||
Skydd_SkyddadFolkbokforing: boolean
|
||||
Namn_Fornamn: string
|
||||
Namn_Mellannamn?: string
|
||||
Namn_Efternamn: string
|
||||
Namn_Tilltalsnamn?: string
|
||||
PersonDetaljer_Kon: string
|
||||
PersonDetaljer_Fodelsedatum: string
|
||||
Folkbokforing_FolkbokfordLanKod?: string
|
||||
Folkbokforing_FolkbokfordKommunKod?: string
|
||||
Folkbokforingsadress_SvenskAdress_CareOf?: string
|
||||
Folkbokforingsadress_SvenskAdress_Utdelningsadress1?: string
|
||||
Folkbokforingsadress_SvenskAdress_Utdelningsadress2?: string
|
||||
Folkbokforingsadress_SvenskAdress_PostNr?: string
|
||||
Folkbokforingsadress_SvenskAdress_Postort?: string
|
||||
}
|
||||
|
||||
export interface CompanyRole {
|
||||
companyId: number
|
||||
companyRegistrationNumber: string
|
||||
legalName: string
|
||||
legalEntityType: string
|
||||
positionTypes: string[]
|
||||
positionDescriptions: string[]
|
||||
positionStart: string
|
||||
positionEnd: string | null
|
||||
companyStatus: string
|
||||
signatureDescription?: string
|
||||
}
|
||||
|
||||
export interface EnrichmentData {
|
||||
personalNumber: string
|
||||
name: string
|
||||
enrichedAtUtc: string
|
||||
spar?: SparData
|
||||
companyRoles?: CompanyRole[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Complete endpoint request/response (gnubok internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BankIdCompleteRequest {
|
||||
sessionId: string
|
||||
mode: 'login' | 'signup'
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface BankIdCompleteResponse {
|
||||
tokenHash: string
|
||||
type: string
|
||||
isNewUser: boolean
|
||||
enrichmentData?: EnrichmentData
|
||||
}
|
||||
|
||||
export interface BankIdCompleteErrorResponse {
|
||||
error: 'no_account' | 'already_linked' | 'session_invalid' | 'session_expired'
|
||||
givenName?: string
|
||||
surname?: string
|
||||
}
|
||||
Reference in New Issue
Block a user