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
+308
View File
@@ -0,0 +1,308 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import Image from 'next/image'
import { BankIdQrCode } from './BankIdQrCode'
import { Button } from '@/components/ui/button'
import { Smartphone, Monitor } from 'lucide-react'
type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account'
interface BankIdSession {
sessionId: string
autoStartToken: string
qrStartToken: string
qrStartSecret: string
}
export interface BankIdResult {
tokenHash?: string
type?: string
isNewUser?: boolean
error?: 'no_account' | 'already_linked' | 'session_invalid'
givenName?: string
surname?: string
sessionId?: string
}
interface BankIdAuthProps {
mode: 'login' | 'signup' | 'link'
onComplete: (result: BankIdResult) => void
}
const API_BASE = '/api/extensions/ext/tic/bankid'
function isMobile(): boolean {
if (typeof navigator === 'undefined') return false
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
}
/**
* BankID authentication flow component.
* Handles QR code display (desktop) or app deep link (mobile),
* polling, and result handling.
*/
export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
const [status, setStatus] = useState<BankIdStatus>('idle')
const [session, setSession] = useState<BankIdSession | null>(null)
const [hintMessage, setHintMessage] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const abortRef = useRef<AbortController | null>(null)
const lastStartRef = useRef<number>(0)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const cleanup = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
}, [])
useEffect(() => cleanup, [cleanup])
const startSession = useCallback(async () => {
// Prevent rapid restarts (each start = billable TIC session)
const now = Date.now()
if (now - lastStartRef.current < 5000) return
lastStartRef.current = now
cleanup()
setStatus('scanning')
setHintMessage('Starta BankID-appen')
setErrorMessage('')
try {
const res = await fetch(`${API_BASE}/start`, { method: 'POST' })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || 'Failed to start BankID')
}
const { data } = await res.json()
const newSession: BankIdSession = data
setSession(newSession)
// On mobile, open BankID app
if (isMobile()) {
const returnUrl = encodeURIComponent(window.location.href)
window.location.href = `bankid:///?autostarttoken=${newSession.autoStartToken}&redirect=${returnUrl}`
}
// Start polling
abortRef.current = new AbortController()
pollRef.current = setInterval(async () => {
try {
const pollRes = await fetch(`${API_BASE}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: newSession.sessionId }),
signal: abortRef.current?.signal,
})
if (!pollRes.ok) return
const pollJson = await pollRes.json()
const pollData = pollJson.data
if (!pollData) {
console.warn('[bankid] poll returned no data:', pollJson)
return
}
// Update hint message from TIC API
if (pollData.message) {
setHintMessage(pollData.message)
}
// Handle token refresh (order regeneration ~25s)
if (pollData.qrStartToken && pollData.qrStartSecret) {
setSession((prev) =>
prev
? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
: prev
)
}
if (pollData.status === 'complete') {
cleanup()
setStatus('complete')
if (mode === 'login') {
// For login, call /complete to exchange for Supabase session
try {
const completeRes = await fetch(`${API_BASE}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: newSession.sessionId,
mode: 'login',
}),
})
const completeJson = await completeRes.json()
if (!completeRes.ok) {
onCompleteRef.current({
error: completeJson.error,
givenName: completeJson.givenName,
surname: completeJson.surname,
})
return
}
onCompleteRef.current({
tokenHash: completeJson.data.tokenHash,
type: completeJson.data.type,
isNewUser: completeJson.data.isNewUser,
})
} catch {
onCompleteRef.current({ error: 'session_invalid' })
}
} else if (mode === 'link') {
// For link, call /link to associate BankID with current user
try {
const linkRes = await fetch(`${API_BASE}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: newSession.sessionId }),
})
const linkJson = await linkRes.json()
if (!linkRes.ok) {
onCompleteRef.current({ error: linkJson.error })
return
}
onCompleteRef.current({})
} catch {
onCompleteRef.current({ error: 'session_invalid' })
}
} else {
// For signup, return user data + sessionId so parent can collect email
onCompleteRef.current({
givenName: pollData.user?.givenName,
surname: pollData.user?.surname,
sessionId: newSession.sessionId,
})
}
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
cleanup()
setStatus('failed')
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return
// Polling error — will retry next tick
}
}, 2000)
} catch (error) {
setStatus('failed')
setErrorMessage(error instanceof Error ? error.message : 'Ett oväntat fel uppstod')
}
}, [cleanup, mode])
const handleCancel = useCallback(async () => {
if (session) {
fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {})
}
cleanup()
setStatus('idle')
setSession(null)
}, [session, cleanup])
if (status === 'idle') {
const label = mode === 'login'
? 'Logga in med BankID'
: mode === 'link'
? 'Koppla BankID'
: 'Skapa konto med BankID'
return (
<Button
onClick={startSession}
variant="outline"
className="w-full gap-2 border-[1.5px] py-6 text-base"
>
<BankIdIcon />
{label}
</Button>
)
}
if (status === 'failed') {
return (
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-destructive">{errorMessage}</p>
<Button onClick={startSession} variant="outline" className="gap-2">
<BankIdIcon />
Forsok igen
</Button>
</div>
)
}
const openBankIdOnDevice = () => {
if (!session) return
// Open BankID app on the same device via deep link
// redirect=null tells BankID not to redirect after completion
window.location.href = `bankid:///?autostarttoken=${session.autoStartToken}&redirect=null`
}
// Scanning / waiting for user
return (
<div className="flex flex-col items-center gap-4">
{session && !isMobile() && (
<>
<BankIdQrCode
qrStartToken={session.qrStartToken}
qrStartSecret={session.qrStartSecret}
/>
<Button
onClick={openBankIdOnDevice}
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground"
>
<Monitor className="h-3.5 w-3.5" />
BankID pa den har enheten
</Button>
</>
)}
{isMobile() && (
<div className="flex flex-col items-center gap-2">
<Smartphone className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Oppnar BankID-appen...</p>
</div>
)}
<p className="text-sm text-muted-foreground">{hintMessage}</p>
<Button
onClick={handleCancel}
variant="ghost"
size="sm"
className="text-muted-foreground"
>
Avbryt
</Button>
</div>
)
}
function BankIdIcon() {
return (
<Image
src="/logos/bankid-seeklogo.svg"
alt="BankID"
width={20}
height={20}
className="dark:invert"
/>
)
}
+86
View File
@@ -0,0 +1,86 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import QRCode from 'qrcode'
interface BankIdQrCodeProps {
qrStartToken: string
qrStartSecret: string
}
/**
* Animated BankID QR code component.
* Computes HMAC-SHA256 every second per BankID spec:
* bankid.{qrStartToken}.{time}.{hmac_sha256(qrStartSecret, time)}
*/
export function BankIdQrCode({ qrStartToken, qrStartSecret }: BankIdQrCodeProps) {
const [svgData, setSvgData] = useState<string>('')
const elapsedRef = useRef(0)
const tokenRef = useRef(qrStartToken)
const secretRef = useRef(qrStartSecret)
// Update refs when tokens change (order regeneration)
useEffect(() => {
tokenRef.current = qrStartToken
secretRef.current = qrStartSecret
elapsedRef.current = 0
}, [qrStartToken, qrStartSecret])
const generateQr = useCallback(async () => {
const token = tokenRef.current
const secret = secretRef.current
const time = elapsedRef.current
try {
// Compute HMAC-SHA256 using Web Crypto API
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(time.toString()))
const qrAuthCode = Array.from(new Uint8Array(signature))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
const qrData = `bankid.${token}.${time}.${qrAuthCode}`
const svg = await QRCode.toString(qrData, {
type: 'svg',
margin: 1,
width: 200,
color: { dark: '#141414', light: '#ffffff' },
})
setSvgData(svg)
} catch {
// Silently fail — next tick will retry
}
elapsedRef.current++
}, [])
useEffect(() => {
// Generate immediately, then every second
generateQr()
const interval = setInterval(generateQr, 1000)
return () => clearInterval(interval)
}, [generateQr])
if (!svgData) {
return (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-lg border bg-white">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)
}
return (
<div
className="inline-flex rounded-lg border bg-white p-2"
dangerouslySetInnerHTML={{ __html: svgData }}
/>
)
}
-65
View File
@@ -1,65 +0,0 @@
'use client'
import { Card } from '@/components/ui/card'
import { User, Users } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Step0Props {
onChooseSelf: () => void
onChooseConsultant: () => void
}
const options = [
{
key: 'self',
label: 'Jag bokför själv',
description: 'Du driver ditt eget företag och vill hantera bokföringen.',
icon: User,
},
{
key: 'consultant',
label: 'Jag bokför åt någon annan',
description: 'Du är konsult eller byrå och bokför åt dina kunder.',
icon: Users,
},
] as const
export default function Step0RoleChoice({ onChooseSelf, onChooseConsultant }: Step0Props) {
return (
<div className="space-y-8">
<div className="grid gap-3">
{options.map((option) => {
const Icon = option.icon
const handler = option.key === 'self' ? onChooseSelf : onChooseConsultant
return (
<button
key={option.key}
type="button"
onClick={handler}
className="text-left w-full"
>
<Card
className={cn(
'relative p-5 transition-all cursor-pointer',
'hover:border-primary/50 hover:shadow-sm',
)}
>
<div className="flex items-start gap-4">
<div className="p-2.5 rounded-lg bg-muted/50">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<span className="font-medium">{option.label}</span>
<p className="text-sm text-muted-foreground mt-0.5">
{option.description}
</p>
</div>
</div>
</Card>
</button>
)
})}
</div>
</div>
)
}
@@ -34,6 +34,7 @@ interface Step2Props {
onNext: (data: FormData) => void
onBack: () => void
isSaving: boolean
orgNumberLocked?: boolean
}
export default function Step2CompanyDetails({
@@ -44,6 +45,7 @@ export default function Step2CompanyDetails({
onNext,
onBack,
isSaving,
orgNumberLocked,
}: Step2Props) {
const {
register,
@@ -167,6 +169,8 @@ export default function Step2CompanyDetails({
id="org_number"
placeholder={isAB ? 'XXXXXX-XXXX' : 'ÅÅMMDD-XXXX (ditt personnummer vid EF)'}
{...register('org_number')}
readOnly={orgNumberLocked}
className={orgNumberLocked ? 'bg-muted cursor-not-allowed' : undefined}
/>
{errors.org_number && (
<p className="text-sm text-destructive">{errors.org_number.message}</p>
+149
View File
@@ -0,0 +1,149 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { BankIdAuth } from '@/components/auth/BankIdAuth'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Shield, ShieldCheck, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
interface BankIdIdentity {
given_name: string | null
surname: string | null
linked_at: string
}
export function BankIdSettings() {
const [identity, setIdentity] = useState<BankIdIdentity | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLinking, setIsLinking] = useState(false)
const [isUnlinking, setIsUnlinking] = useState(false)
const { toast } = useToast()
const fetchIdentity = useCallback(async () => {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) { setIsLoading(false); return }
const { data } = await supabase
.from('bankid_identities')
.select('given_name, surname, linked_at')
.eq('user_id', user.id)
.maybeSingle()
setIdentity(data)
setIsLoading(false)
}, [])
useEffect(() => {
fetchIdentity()
}, [fetchIdentity])
const handleLinkComplete = async (result: BankIdResult) => {
if (result.error) {
const message = result.error === 'already_linked'
? 'Detta BankID ar redan kopplat till ett annat konto.'
: 'Kunde inte koppla BankID.'
toast({ title: message, variant: 'destructive' })
setIsLinking(false)
return
}
toast({ title: 'BankID kopplat till ditt konto' })
setIsLinking(false)
fetchIdentity()
}
const handleUnlink = async () => {
if (!confirm('Vill du koppla bort BankID fran ditt konto?')) return
setIsUnlinking(true)
try {
const res = await fetch('/api/extensions/ext/tic/bankid/unlink', { method: 'POST' })
if (!res.ok) throw new Error('Unlink failed')
setIdentity(null)
toast({ title: 'BankID bortkopplat' })
} catch {
toast({ title: 'Kunde inte koppla bort BankID', variant: 'destructive' })
} finally {
setIsUnlinking(false)
}
}
if (isLoading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
if (isLinking) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Koppla BankID</CardTitle>
<CardDescription>Skanna QR-koden med BankID-appen</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
{identity ? (
<ShieldCheck className="h-4 w-4 text-green-600" />
) : (
<Shield className="h-4 w-4 text-muted-foreground" />
)}
BankID
</CardTitle>
<CardDescription>
{identity
? 'Ditt konto ar kopplat till BankID.'
: 'Koppla BankID for sakrare inloggning.'}
</CardDescription>
</CardHeader>
<CardContent>
{identity ? (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{identity.given_name} {identity.surname}
</span>
<span className="ml-2">
Kopplat {new Date(identity.linked_at).toLocaleDateString('sv-SE')}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleUnlink}
disabled={isUnlinking}
className="text-destructive hover:text-destructive"
>
{isUnlinking ? 'Kopplar bort...' : 'Koppla bort'}
</Button>
</div>
) : (
<Button
variant="outline"
onClick={() => setIsLinking(true)}
>
Koppla BankID
</Button>
)}
</CardContent>
</Card>
)
}
+4
View File
@@ -10,9 +10,12 @@ import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, ShieldCheck, ShieldOff, KeyRound } from 'lucide-react'
import { isMfaRequired } from '@/lib/auth/mfa'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { BankIdSettings } from '@/components/settings/BankIdSettings'
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
const mfaRequired = isMfaRequired()
const bankIdEnabled = isBankIdEnabled()
export function SecuritySettings() {
const [newPassword, setNewPassword] = useState('')
@@ -132,6 +135,7 @@ export function SecuritySettings() {
return (
<div className="space-y-6">
{bankIdEnabled && <BankIdSettings />}
{/* Change password */}
<Card>
<CardHeader>