Files
accounted/components/auth/BankIdAuth.tsx
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

744 lines
29 KiB
TypeScript

'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import Image from 'next/image'
import { useTranslations } from 'next-intl'
import { BankIdQrCode } from './BankIdQrCode'
import { Button } from '@/components/ui/button'
import { Smartphone, Monitor, AlertTriangle, Loader2 } from 'lucide-react'
type BankIdStatus =
| 'idle'
| 'scanning'
| 'complete'
| 'failed'
| 'no_account'
| 'service_unavailable'
/** A flow exists in this browser that this browsing context did not start. */
| 'resumable'
/** Avbryt pressed: waiting out /cancel before a new flow may be started. */
| 'cancelling'
/** Max consecutive poll failures before we declare service unavailable */
const MAX_POLL_FAILURES = 3
/**
* Abandon a session that never reaches a terminal state. Safety net for TIC
* responses that carry no `status` (e.g. an expired-session 410 body): without
* it the poll loop would spin forever on a dead QR code.
*/
const POLL_DEADLINE_MS = 6 * 60 * 1000
/** Min spacing between billable TIC session starts (mirrors the server cooldown). */
const START_COOLDOWN_MS = 5_000
/**
* What the client is allowed to know about a BankID order. Note the absence
* of a session id: it is a bearer credential for the holder's personnummer
* and for a Supabase session, so it stays in a signed HttpOnly cookie the
* server sets at /start (extensions/general/tic/lib/bankid-flow-cookie.ts).
* The tokens below are the ones the BankID app and the QR code need, and
* neither identifies anybody.
*/
interface BankIdSession {
flowId: string
autoStartToken: string
qrStartToken: string
qrStartSecret: string
}
export interface BankIdResult {
tokenHash?: string
type?: string
isNewUser?: boolean
error?: 'no_account' | 'already_linked' | 'session_invalid' | 'service_unavailable' | 'email_unconfirmed'
/** Server-provided Swedish explanation for errors that carry one (e.g. email_unconfirmed). */
message?: string
givenName?: string
surname?: string
/** Non-secret id that binds this tab to the shared server-held flow. */
flowId?: string
}
interface BankIdAuthProps {
mode: 'login' | 'signup' | 'link'
onComplete: (result: BankIdResult) => void
/**
* Render the idle button as the panel's primary action (filled pill) instead
* of the default outline. Used by the login page, where BankID owns the
* primary zone; register and settings keep the quieter outline.
*/
hero?: boolean
}
const API_BASE = '/api/extensions/ext/tic/bankid'
const FLOW_ID_HEADER = 'x-bankid-flow-id'
function isMobile(): boolean {
if (typeof navigator === 'undefined') return false
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
}
/**
* Launch the BankID app on the same (mobile) device.
*
* Uses the universal link https://app.bankid.com/: NOT the bankid:/// custom
* scheme. A custom-scheme launch has no association with the originating Safari
* tab, so on iOS the post-auth redirect opens in a NEW tab (git history: commit
* 3bc652cc reverted a redirect for exactly that reason).
*
* redirect:
* • iOS → current URL, so the app navigates back here on success. In
* Safari that is this very tab; in a third-party browser or an
* in-app web view the OS hands the URL to the default browser
* and it lands in a NEW tab. Both work: the flow lives in a
* cookie, which every tab of the origin shares, so whichever tab
* the user ends up in simply polls and carries on.
* • Android → "null": the BankID app returns via the task stack, and a real
* redirect URL would spawn a new tab / Chrome instance instead.
*/
function launchBankIdApp(autoStartToken: string): void {
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
const isIOS = /iPad|iPhone|iPod/.test(ua)
const redirect = isIOS ? encodeURIComponent(window.location.href) : 'null'
window.location.href = `https://app.bankid.com/?autostarttoken=${autoStartToken}&redirect=${redirect}`
}
/**
* BankID authentication flow component.
* Handles QR code display (desktop) or app deep link (mobile),
* polling, and result handling.
*/
export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps) {
const t = useTranslations('auth')
const [status, setStatus] = useState<BankIdStatus>('idle')
const [session, setSession] = useState<BankIdSession | null>(null)
const [activeFlowId, setActiveFlowId] = useState<string | null>(null)
const [hintMessage, setHintMessage] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
const [launchedApp, setLaunchedApp] = useState(false)
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 pollFailureCount = useRef(0)
/** True while a poll response is being processed: prevents overlapping ticks. */
const pollInFlightRef = useRef(false)
/** Set once a terminal poll result has been handled: the completion branch must run at most once. */
const completedRef = useRef(false)
/** When the current poll loop began, for the POLL_DEADLINE_MS cap. */
const pollStartedAtRef = useRef(0)
/** Bumped by cancel/unmount so an in-flight startSession stops touching state. */
const startGenRef = useRef(0)
/** True while startSession is running: collapses double-clicks into one billable session. */
const startingRef = useRef(false)
/**
* True when this tab is polling a flow it did not start, on the strength of
* the hint cookie alone. Decides how a 404 reads: nothing to resume (quietly
* show the button) versus this tab's own order expiring (say so).
*/
const resumedRef = useRef(false)
const cleanup = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
if (abortRef.current) {
abortRef.current.abort()
abortRef.current = null
}
pollFailureCount.current = 0
pollInFlightRef.current = false
}, [])
useEffect(
() => () => {
startGenRef.current++
cleanup()
},
[cleanup]
)
// Poll the flow this browser holds until it completes, fails, or the service
// gives up. Extracted from startSession so the resume effect (the tab BankID
// returned the user to) can attach to a flow it did not start.
const beginPolling = useCallback((flowId: string) => {
// Never leave a previous interval running: two loops would share
// pollFailureCount and completedRef, and the orphan would eventually
// declare a healthy session unavailable and clear the live one.
cleanup()
abortRef.current = new AbortController()
pollStartedAtRef.current = Date.now()
completedRef.current = false
pollInFlightRef.current = false
pollRef.current = setInterval(async () => {
// Never process two ticks concurrently: a slow response overlapping the
// next tick could otherwise run the completion branch twice (double
// /complete → double generateLink, which invalidates the first magic
// link and fails the login intermittently).
if (pollInFlightRef.current || completedRef.current) return
// Hard cap: a session that never reaches a terminal state (expired
// order, TIC response without `status`) must not poll forever.
if (Date.now() - pollStartedAtRef.current > POLL_DEADLINE_MS) {
cleanup()
setStatus('failed')
setErrorMessage('BankID-sessionen löpte ut. Försök igen.')
return
}
pollInFlightRef.current = true
try {
// The server reads the secret session from the cookie. The body and
// non-secret header bind this polling loop to its mode and flow id.
const pollRes = await fetch(`${API_BASE}/poll`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[FLOW_ID_HEADER]: flowId,
},
body: JSON.stringify({ mode }),
signal: abortRef.current?.signal,
})
// The server says this browser holds no live flow: it finished in
// another tab, was cancelled, or expired. Terminal, and NOT a service
// failure, so settle instead of spinning until the deadline.
//
// Matched on the body code, not the bare status: 404 is also what the
// extension dispatcher returns for an unknown route and what the CDN
// returns mid-deploy, and treating those as "your session died" would
// abandon a live order and cost a second billable one.
if (pollRes.status === 404) {
const reason = await pollRes.json().catch(() => null)
if (reason?.error !== 'no_session') {
pollFailureCount.current++
return
}
completedRef.current = true
cleanup()
if (resumedRef.current) {
// We only probed on the chance a flow existed. It does not, so
// quietly offer the start button.
setStatus('idle')
} else {
// This tab watched its own order die. Say so: silently reverting to
// the start button reads as "it just did nothing".
setStatus('failed')
setErrorMessage('BankID-sessionen löpte ut. Försök igen.')
}
setSession(null)
setActiveFlowId(null)
return
}
if (!pollRes.ok) {
// Count EVERY failed poll (5xx, 429, unexpected 4xx): errors that
// never increment the counter would otherwise leave the user
// silently polling a dead session forever.
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
return
}
// Reset failure counter on successful poll
pollFailureCount.current = 0
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)
}
// Token refresh (the order regenerates roughly every 25s). Adopted
// even when this tab has no session of its own: that is how a resumed
// tab (a reload, or the tab BankID returned the user to) gets a
// renderable QR code instead of a spinner with nothing under it.
if (pollData.qrStartToken && pollData.qrStartSecret) {
setSession((prev) => ({
flowId,
autoStartToken: prev?.autoStartToken ?? '',
qrStartToken: pollData.qrStartToken,
qrStartSecret: pollData.qrStartSecret,
}))
}
if (pollData.status === 'complete') {
completedRef.current = true
cleanup()
setStatus('complete')
if (mode === 'login') {
// For login, call /complete to exchange for Supabase session.
// Session and mode come from the flow cookie; the server clears it
// as it answers, so this is single-use and a second tab that also
// saw 'complete' gets session_invalid rather than minting a rival
// magic link that would invalidate this one.
try {
const completeRes = await fetch(`${API_BASE}/complete`, {
method: 'POST',
headers: { [FLOW_ID_HEADER]: flowId },
})
const completeJson = await completeRes.json()
if (!completeRes.ok) {
const errorCode = completeJson.error === 'service_unavailable' || completeRes.status === 502 || completeRes.status === 503
? 'service_unavailable' as const
: completeJson.error
if (errorCode === 'service_unavailable') {
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
}
onCompleteRef.current({
error: errorCode,
message: typeof completeJson.message === 'string' ? completeJson.message : undefined,
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. The
// server requires the flow cookie to have been opened in 'link'
// mode, so a session started to sign someone in cannot be turned
// into a binding against whoever is logged in here.
try {
const linkRes = await fetch(`${API_BASE}/link`, {
method: 'POST',
headers: { [FLOW_ID_HEADER]: flowId },
})
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, hand the parent the verified name so it can collect
// an e-mail. The flow itself stays in the cookie, so the parent
// posts only that e-mail to /complete.
onCompleteRef.current({
givenName: pollData.user?.givenName,
surname: pollData.user?.surname,
flowId,
})
}
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
completedRef.current = true
cleanup()
setStatus('failed')
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
}
} finally {
pollInFlightRef.current = false
}
}, 2000)
}, [cleanup, mode])
const startSession = useCallback(async () => {
// Collapse double-clicks: one billable TIC session per intent.
if (startingRef.current) return
startingRef.current = true
const gen = ++startGenRef.current
cleanup()
resumedRef.current = false
setActiveFlowId(null)
setLaunchedApp(false)
setStatus('scanning')
setHintMessage('Starta BankID-appen')
setErrorMessage('')
try {
// Respect the billable-session cooldown by waiting out the remainder
// instead of silently dropping the click: a retry button that does
// nothing reads as "BankID is broken". Also keeps us under the
// server-side per-IP cooldown on /start.
const sinceLast = Date.now() - lastStartRef.current
if (sinceLast < START_COOLDOWN_MS) {
await new Promise((resolve) => setTimeout(resolve, START_COOLDOWN_MS - sinceLast))
}
if (gen !== startGenRef.current) return // cancelled/unmounted while waiting
lastStartRef.current = Date.now()
// The mode is pinned server-side into the flow cookie, so the session
// this opens can only ever be finished as this kind of flow.
const res = await fetch(`${API_BASE}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode }),
})
if (gen !== startGenRef.current) return
if (!res.ok) {
const err = await res.json().catch(() => ({}))
if (err.error === 'service_unavailable' || err.error === 'not_configured' || res.status === 502 || res.status === 503) {
cleanup()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
return
}
if (res.status === 429 || err.error === 'rate_limit') {
setStatus('failed')
setErrorMessage('För många försök. Vänta en stund och försök igen.')
return
}
// Unknown error: server messages are not user-facing copy; keep the
// detail in the console and show Swedish.
console.error('[bankid] start failed', res.status, err)
setStatus('failed')
setErrorMessage('Ett oväntat fel uppstod. Försök igen.')
return
}
const { data } = await res.json()
const newSession: BankIdSession = data
setSession(newSession)
setActiveFlowId(newSession.flowId)
// On mobile, open the BankID app on this device. Nothing needs saving
// first: /start already set the flow cookie, and a cookie is shared by
// every tab of the origin, so whichever tab BankID returns the user to
// (this one reloaded, or a brand new one) can resume from it.
if (isMobile()) {
setLaunchedApp(true)
launchBankIdApp(newSession.autoStartToken)
}
beginPolling(newSession.flowId)
} catch (error) {
if (gen !== startGenRef.current) return
console.error('[bankid] start failed', error)
setStatus('failed')
setErrorMessage('Ett oväntat fel uppstod. Försök igen.')
} finally {
startingRef.current = false
}
}, [cleanup, mode, beginPolling])
/**
* Start (or continue) polling the flow this browser holds. Called from the
* "Fortsätt" confirm button and from startSession, never automatically on
* mount: see the probe effect below for why.
*/
const resumePolling = useCallback(() => {
if (!activeFlowId) return
resumedRef.current = true
setStatus('scanning')
setHintMessage(t('bankid_resume_hint'))
beginPolling(activeFlowId)
}, [activeFlowId, beginPolling, t])
/**
* On mount, ask ONCE whether this browser is mid-flow, and if so present a
* confirm card rather than continuing automatically.
*
* This is the fix for the reported bug and the safe shape for it. BankID does
* not reliably return the user to the tab that started the flow: on iOS
* outside plain Safari the redirect opens a NEW tab, and even the same tab is
* reloaded. The flow lives in a shared cookie now, so any of those tabs can
* pick it up. But a shared cookie plus a shared machine means the tab that
* finds a completed identification cannot prove the person sitting at it is
* the one who made it: nothing the client can store survives an iOS reload
* yet dies on "reopen closed tab" / session restore / tab duplication, so a
* completed flow could otherwise be auto-consumed by the next person to open
* the page and sign them into, or bind their account to, a stranger.
*
* So a resume is never automatic: a found flow routes to the confirm card
* ("Fortsätt bara om det var du"), and only that click consumes it. The cost
* is one tap after returning from the BankID app on iOS, which is where the
* reported bug lives; desktop QR (no reload) and Android (returns to the live
* tab) never hit this path and are unchanged. `link` never resumes at all.
*/
useEffect(() => {
if (mode === 'link') return
let cancelled = false
const gen = startGenRef.current
void (async () => {
try {
const res = await fetch(`${API_BASE}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, probe: true }),
})
// No live flow this browser can point to (no cookie, or one for a
// different mode): leave the start button.
if (cancelled || res.status === 404 || !res.ok) return
const json = await res.json().catch(() => null)
const status = json?.data?.status
if (!status || status === 'failed' || status === 'cancelled') return
// A start begun while the probe was in flight owns the panel; do not
// override it with a confirm card for the flow it is replacing.
if (cancelled || gen !== startGenRef.current || startingRef.current) return
// A live flow exists. It may be this person returning from the BankID
// app, or a stranger's identification left on a shared machine; the two
// are indistinguishable from here, so it is theirs to confirm, never
// ours to spend. The probe deliberately gets no holder name from /poll,
// so the card cannot reveal whose identification it is.
const flowId = json?.data?.flowId
if (typeof flowId !== 'string' || !flowId) return
setActiveFlowId(flowId)
setStatus('resumable')
} catch {
// Offline or a blip: leave the start button. A flow that really is
// live will still be there when they press it (or on the next load).
}
})()
return () => {
cancelled = true
}
// Mount only: we are asking whether this browser is mid-flow, which cannot
// change without one of this component's own actions.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleCancel = useCallback(async () => {
startGenRef.current++ // stop an in-flight startSession from resuming
cleanup()
setSession(null)
// Hold a 'cancelling' state until /cancel resolves, rather than going
// straight to 'idle'. /cancel clears the flow cookie with an UNTARGETED
// Set-Cookie, and it round-trips to TIC before responding, so if the start
// button were reachable now the user could open a new flow whose fresh
// cookie the late clear would then delete. No start button is rendered in
// 'cancelling', so a new /start cannot race the clear.
setStatus('cancelling')
try {
await fetch(`${API_BASE}/cancel`, {
method: 'POST',
headers: activeFlowId ? { [FLOW_ID_HEADER]: activeFlowId } : undefined,
})
} catch {
// Unreachable server: the flow expires on its own.
} finally {
setActiveFlowId(null)
setStatus('idle')
}
}, [activeFlowId, cleanup])
if (status === 'cancelling') {
return (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('bankid_cancelling')}
</div>
)
}
if (status === 'resumable') {
return (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="space-y-3">
<div className="space-y-2">
<p className="text-sm font-medium">{t('bankid_resume_title')}</p>
<p className="text-sm text-muted-foreground">
{/* Deliberately does not say who: naming the holder would leak a
stranger's identity to whoever sits down at a shared machine.
Signup shows the verified name on the next step, after the
person here has claimed the identification as theirs. */}
{t('bankid_resume_description')}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={resumePolling} variant="default" className="h-11 gap-2">
<BankIdIcon className="invert dark:invert-0" />
{t('bankid_resume_continue')}
</Button>
<Button onClick={handleCancel} variant="outline" className="h-11">
{t('bankid_resume_restart')}
</Button>
</div>
</div>
</div>
)
}
if (status === 'complete') {
return (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('bankid_completing')}
</div>
)
}
if (status === 'idle') {
const label = mode === 'login'
? 'Logga in med BankID'
: mode === 'link'
? 'Koppla BankID'
: 'Skapa konto med BankID'
return (
<Button
onClick={startSession}
variant={hero ? 'default' : 'outline'}
className={hero ? 'h-11 w-full gap-2' : 'w-full gap-2 border-[1.5px] py-6 text-base'}
>
{/* On the filled pill the logo must counter-invert: primary is dark in
light mode (white logo) and light in dark mode (black logo). */}
<BankIdIcon className={hero ? 'invert dark:invert-0' : undefined} />
{label}
</Button>
)
}
if (status === 'service_unavailable') {
return (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-attn" />
<div className="space-y-1.5">
<p className="text-sm font-medium">
BankID är inte tillgängligt just nu
</p>
<p className="text-sm text-muted-foreground">
{mode === 'login'
? 'Logga in med e-post och lösenord nedan, eller använd "Glömt lösenord?" för en inloggningslänk via e-post.'
: mode === 'signup'
? 'Skapa konto med e-post och lösenord nedan istället.'
: 'Försök igen senare.'}
</p>
<Button
onClick={startSession}
variant="ghost"
size="sm"
className="mt-1 h-auto px-0 py-0 text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
Försök med BankID igen
</Button>
</div>
</div>
</div>
)
}
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 />
Försök 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}
/>
{/* A resumed tab never called /start, so it has the rotating QR
tokens but no autostart token. Rendering the button anyway would
deep-link `autostarttoken=` empty, which does nothing and says
nothing. The QR above still works. */}
{session.qrStartToken && session.qrStartSecret && session.autoStartToken && (
<Button
onClick={openBankIdOnDevice}
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground"
>
<Monitor className="h-3.5 w-3.5" />
BankID på den här enheten
</Button>
)}
</>
)}
{isMobile() && (
<div className="flex flex-col items-center gap-2">
<Smartphone className="h-8 w-8 text-muted-foreground" />
{/* A resumed tab never launched anything, so saying "opening the
BankID app" would be a lie followed by silence. */}
<p className="text-sm text-muted-foreground">
{launchedApp
? 'Öppnar BankID-appen...'
: t('bankid_finish_in_app')}
</p>
</div>
)}
<p className="text-sm text-muted-foreground">{hintMessage}</p>
<Button
onClick={handleCancel}
disabled={!activeFlowId}
variant="ghost"
size="sm"
className="text-muted-foreground"
>
Avbryt
</Button>
</div>
)
}
function BankIdIcon({ className }: { className?: string }) {
return (
<Image
src="/logos/bankid-seeklogo.svg"
alt="BankID"
width={20}
height={20}
loading="eager"
className={className ?? 'dark:invert'}
/>
)
}