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:
Jakob Wennberg
2026-04-08 13:44:14 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6486e0d9e2
commit 6d75b9a1bf
31 changed files with 3186 additions and 274 deletions
@@ -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' })
)
})
})
})
+431
View File
@@ -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: [],
+148
View File
@@ -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()
}
+159
View File
@@ -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
}