diff --git a/DECISIONS.md b/DECISIONS.md
index 904f1aea..b7101c32 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -993,6 +993,10 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-08-14] /sie-data validation stays newest-file-only (not per-file, not on the merged parse): preserves exactly which datasets are accepted today, and validateSIEFile assumes single-file invariants (balance yearIndexes relative to ONE current year) that mergeParsedSIEFiles deliberately does not preserve. Older files' problems still surface per-file at import time.
[2026-08-14] SKV manual-verifikat deep link payload moved from URL params to single-use sessionStorage (supersedes same-day URL-params decision): compliance swarm flagged financial data in query strings landing in history/access logs/Referer (GDPR Art.5(1)(f), ISO A.8.12); URL now carries only the opaque row id.
[2026-08-14] SKV prefill sessionStorage XSS window accepted as residual risk (ISO A.8.12 low, swarm PR #1621): script execution already implies full ledger read via authenticated APIs; a server-issued staging token adds a roundtrip, not protection. Documented in manual-verifikat-prefill.ts header.
+[2026-08-15] BankID mobile stranded-tab fix: the session id never reaches the browser; it lives in a signed `__Host-` HttpOnly cookie (extensions/general/tic/lib/bankid-flow-cookie.ts) that /poll, /complete, /link and /cancel read. Reported bug: BankID does not reliably return the user to the tab that started the flow (outside plain Safari the iOS https redirect goes to the OS, which opens a NEW tab), the session lived in per-tab sessionStorage, so the new tab rendered the start button and the completed signup was stranded. Login hid it because its completion is self-finishing and the Supabase session is a cookie every tab shares. Cookies being origin-wide is exactly what the handoff needed, so there is no client storage, no cross-tab lock and no heartbeat. TWO REJECTED ATTEMPTS, both killed by adversarial review before merge, both worth not re-deriving. (1) localStorage plus an owner/heartbeat lock: a TIC sessionId is an unauthenticated bearer credential (/bankid/poll is skipAuth and returned user.personalNumber verbatim; /bankid/complete with mode 'login' returns a tokenHash that verifyOtp turns into a session, MFA skipped via bankid_linked), so origin-wide persistent storage turned ANY same-origin XSS into a login-fixation primitive, and the design also deadlocked in the very flow it fixed (the hidden tab stopped polling, the new tab cleared the record, and the storage event never reaches a tab iOS has frozen). (2) The first cookie version: it pinned `mode` but not the USER, so on a shared browser an abandoned link flow bound the first person's personnummer to the second person's account, which is the same class of flaw that killed (1); its cookie also used a narrow Path, which a script can shadow by setting the same name at a LONGER path (RFC 6265 sends longer paths first, and a Max-Age=0 at the shorter path cannot delete it); and its "single-use" was a Set-Cookie, which does not serialise two requests that already carried the cookie. Consequences now, all deliberate: `__Host-` with Path=/ and unconditional Secure, because the prefix forbids Domain and forces Path=/, leaving exactly one possible (name, domain, path) and making both the longer-path shadow and a subdomain toss unsettable (Secure costs nothing since isBankIdEnabled() is false when NEXT_PUBLIC_SELF_HOSTED is set, so the earlier conditional-Secure only risked shipping unprotected behind a proxy that omits x-forwarded-proto); readBankIdFlow fails closed on two cookies of the name rather than picking a winner; SameSite=Lax not Strict, because the BankID return is a top-level navigation from outside the site; a `link` flow requires auth at /start and pins userId, and /link rejects a flow belonging to anyone else; single-use is a unique index (bankid_consumed_sessions, migration 20260815120000) claimed BEFORE generateLink, since serverless has no shared memory and a second magic link invalidates the first; the claim fails closed on any error other than 23505; account_exists deliberately does NOT consume, so a mistyped e-mail is correctable; the window is 300s for the order and re-issued at 900s once /poll observes completion, because the signup e-mail step is a person typing and on the old design it was bounded only by TIC's retention; /poll whitelists its response fields, so the personnummer is no longer returned to anyone, and clears the flow when TIC 404/410s or answers without a status, so a dead session cannot make BankID look unavailable for the rest of the window. The client resumes from ONE probe against /poll, never a readable hint cookie: a hint goes stale, cannot tell a live order from a dead one, and made the component poll on mount in states where nothing was in flight. `link` never auto-resumes at all, since picking one up without a click is how an abandoned flow binds to the next person. Android stays on redirect=null; no Android report motivates changing it and #194 closed that path deliberately.
+[2026-08-15] BankID flow cookie, final security invariants after a third skeptic round refuted the user-pinned-cookie version: a signed HttpOnly cookie is NOT a browser binding. It proves the server minted the value, not that this browser started the flow, so an attacker can mint one with curl, authenticate it with their own BankID, and leave it in a shared browser; and for login/signup there is no user to pin (unlike link). The auto-resume that fixes the stranded-tab bug is therefore the thing that made a completed identification transferable between PEOPLE: the next person to open /login (BankID is the default panel) would be signed straight into the planter's tenant with no prompt, and on /register would be invited to finish a signup binding the planter's personnummer to an account made with their own e-mail. Fix: a completed flow is resumed AUTOMATICALLY only when a per-tab sessionStorage marker (lib/auth/bankid-flow-ownership.ts) says the person here started it; otherwise BankIdAuth shows a "fortsätt bara om det var du" confirm card (status 'resumable') that never names the holder (naming would leak a stranger's identity). The marker is per-tab and cleared at every terminal state and on cancel, so a stale marker cannot authorise resuming a same-mode flow another tab started; the BankID app returning to a NEW tab lands in a tab without the marker, so it too must confirm, which is correct. `link` still never auto-resumes at all. Second, the re-issue-on-completion window (900s so the signup e-mail step does not expire mid-typing) was unbounded: /poll pushes expiresAt out, and with no fixed origin a caller could poll in a loop to keep a usable identification alive for TIC's whole retention. Added startedAt to the signed state and a MAX_TOTAL_LIFE cap (order window + verified window) enforced in verifyBankIdFlow. Third, /poll and the dead-session branch no longer CLEAR the cookie: a clearing Set-Cookie is untargeted, so a slow response about a dead session would delete whatever flow is in the jar by the time it lands, including one just started in another tab; the client settles on the status instead and the cookie expires on its own. Only /cancel (the user pressing Avbryt, client-awaited before starting anything new) and the terminal /complete + /link exits clear. Also: /poll rejects a body mode that does not match the cookie's mode (a login session must not be finishable through the signup panel); every /complete and /link exit that has minted or consumed settles; readBankIdFlow catches decodeURIComponent so a malformed cookie is "no flow" not a 500; the raw session id is logged only as an 8-char prefix. Single-use is bankid_consumed_sessions (migration 20260815120000) claimed before generateLink, fail-closed on any non-23505 error, so a deploy MUST apply the migration before the code or every BankID auth returns session_invalid.
+[2026-08-15] BankID flow, corrections after a fourth skeptic round. (1) sessionStorage is NOT "gone when the tab closes": it is restored by reopen-closed-tab (Ctrl+Shift+T), browser session restore, and tab duplication, so the per-tab ownership marker can be inherited by a different person, which reopened the exact silent-auto-login-as-a-stranger vuln the marker was meant to close. The marker is therefore no longer trusted on its own: BankIdAuth auto-resumes (no confirm) ONLY a flow that is still PENDING on the mount probe's first observation AND flowStartedHere(mode). An already-`complete` flow finished before this mount existed and always routes to the confirm card. The pending check is the real boundary (you can only auto-consume a flow you watched complete live); the marker just distinguishes same-tab-reload from arrived-from-elsewhere among pending flows. (2) The component's own Avbryt raced the untargeted /cancel cookie clear: it set status 'idle' synchronously (start button reachable) then awaited /cancel, whose late Set-Cookie Max-Age=0 could delete a freshly started flow's cookie. Added a 'cancelling' status that renders a spinner and no start button until /cancel resolves, so no /start can race the clear. (The register Tillbaka already had the isCancelling disable.) (3) /poll returned the holder's givenName/surname on complete, readable by a probe before the confirm gesture, contradicting the card's no-name guarantee. The name is now withheld from a probe (request carries { mode }) and returned only to the active poll loop (no mode), which the signup e-mail step needs and which runs only after ownership/confirm. Confirmed sound and not changed: the MAX_TOTAL_LIFE cap (startedAt in the signed payload, re-issue preserves it, hard cap 1200s from /start); /poll not clearing the cookie; single-use consume before every mint; the mode-match check skipping the active poll (empty body → mode undefined). Known residual, documented in the PR: a person at the EXACT same abandoned tab another left mid-flow is out of any client marker's reach and degrades to ordinary unattended-authenticated-tab risk.
+[2026-08-15] BankID resume: the per-tab sessionStorage ownership marker (bankid-flow-ownership.ts) was REMOVED, superseding the two prior entries that built on it. It could not close the residual it was meant to: sessionStorage is restored by reopen-closed-tab/session-restore/tab-duplication, so the marker can be inherited by a different person, and there is no client signal that survives an iOS same-tab reload (a navigation) yet dies on a restore (also a fresh load) - both look identical. Since no client-side token can prove "the person here is the one who started this flow" across any fresh document load, the only correct rule is to never auto-consume a resumed flow: the mount probe now routes ANY found live flow to the confirm card ("Fortsätt bara om det var du"), and only that click starts polling and consumes. Auto-consume happens ONLY inside the live component instance that called startSession (desktop QR click; the pre-navigation mobile launch), which by construction is the person who started it. Cost: one "Fortsätt" tap after returning from the BankID app on iOS (new tab or same-tab reload) - exactly the population with the reported stranded-tab bug, for whom a labelled one-tap continue is clearer than a silent resume anyway. Desktop QR (no reload, live component) and Android (returns to the live tab without reload) never hit the resume path and are unchanged. This fully closes both the complete-flow and pending-flow restore vectors and deletes the module plus its unsound premise.
[2026-08-15] Underlag import (attaching a folder of receipts to SIE-migrated verifikat) matches on journal_entries.source_voucher_series/number, never on our own voucher_number: the importer renumbers per target series, so a file named after our number would land on the wrong verifikat exactly when the import skipped an empty/unbalanced voucher. The (period, series, number) resolver was lifted out of extensions/general/arcim-migration into lib/documents/voucher-ref-resolver.ts and is now shared by the provider sweep and the filename flow, so both have one resolution truth.
[2026-08-15] Underlag import is a SEPARATE optional import mode (/import?mode=underlag), not a step inside the SIE wizard (founder call 2026-08-15): the receipts normally arrive later and from a different export, so a migration must never be blocked on having them ready.
[2026-08-15] A filename that parses to a number with no series (31.pdf) is resolved but NEVER auto-selected, even when the lookup returns exactly one candidate, and a date-shaped name is refused outright rather than read as a voucher number. Linking a document to a posted verifikat is irreversible räkenskapsinformation (BFL 7 kap), so the cost of a wrong parse is permanent and the cost of asking is one click. The date guard is deliberately looser than the parser (unpadded components, two-digit years, space and slash separators, and any bare four-digit year-shaped number): a false positive costs one manual assignment, a false negative costs a permanent wrong link.
@@ -1009,3 +1013,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-08-15] Declined (recorded per /resolve-pr triage): classifying validateDocumentFile failures by regex on the localized message in the attach route stays as-is; it mirrors the established pattern in app/api/documents/route.ts, and moving the validator to coded returns is a core-service contract change outside this PR. The Swedish-review notes on idempotency (content hash IS in deterministicDocumentId) and the period-lock error regex (DB trigger is the real guard) require no change.
[2026-08-15] Underlag may attach only to posted or reversed verifikat, enforced in the attach route AND in the resolver reads (Swedish-review round on PR #1627). The SIE import RPC posts every entry inside its own transaction, so a draft with a source ref should be unobservable; the enforcement exists because the link is irreversible and an invariant living in another file is not one this surface may lean on. Reversed stays attachable: a storno'd original remains räkenskapsinformation and its underlag belongs on it.
[2026-08-15] Confirmed intentional (Swedish-review note): with override=true and an unresolvable filename, the attach endpoint links a document to any same-company, same-declared-year, posted verifikat, migrated or not. This mirrors /api/documents/[id]/link, which imposes no filename check at all, so it introduces no new capability class; tenant, year and period-lock enforcement always apply.
+[2026-08-15] BankID tabs bind to a random non-secret `flowId` signed into the shared flow cookie and sent as a request header after start or explicit resume: mode pinning alone cannot distinguish two same-mode tabs, so an older tab could otherwise silently follow, cancel, or complete a newer person's identification after `/start` replaced the origin-wide cookie. This supersedes the 2026-08-15 decision that deliberately skipped mode matching on active polls.
+[2026-08-15] Did not apply BankID migration `20260815120000` to Supabase staging during PR #1625 follow-through: read-only reconciliation found 14 staging-only and 99 branch-only migration versions, so applying on top of that divergent ledger would violate the no-orphan rule. Production is reconciled with zero remote-only versions and exactly this PR migration local-only; hosted pg-real validates the migration until staging is reconciled.
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx
index 9bea6a0e..516a7288 100644
--- a/app/(auth)/register/page.tsx
+++ b/app/(auth)/register/page.tsx
@@ -62,11 +62,12 @@ function RegisterPageContent() {
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
+ const [isCancelling, setIsCancelling] = useState(false)
const [isRegistered, setIsRegistered] = useState(false)
const [duplicateEmail, setDuplicateEmail] = useState(null)
const [inviteEmail, setInviteEmail] = useState(null)
const [bankIdUser, setBankIdUser] = useState<{ givenName?: string; surname?: string } | null>(null)
- const [bankIdSessionId, setBankIdSessionId] = useState(null)
+ const [bankIdFlowId, setBankIdFlowId] = useState(null)
const [bankIdEmail, setBankIdEmail] = useState('')
// Signup failures render inline next to the form (see AuthFormError), never
// as a toast. Field-level problems attach to their field; everything else
@@ -166,29 +167,37 @@ function RegisterPageContent() {
setFormError({ kind: 'bankid', message: t('bankid_failed_description') })
return
}
- // BankID verified: store sessionId and show email form
+ // BankID verified: show the email form. The session itself stays in the
+ // server's HttpOnly flow cookie, so there is nothing to hold on to here.
setFormError(null)
setBankIdUser({ givenName: result.givenName, surname: result.surname })
- if (result.sessionId) setBankIdSessionId(result.sessionId)
+ setBankIdFlowId(result.flowId ?? null)
}
const handleBankIdSignup = async (e: React.FormEvent) => {
e.preventDefault()
setFormError(null)
- setIsLoading(true)
const formData = new FormData(e.currentTarget)
const emailValue = (formData.get('bankid_email') as string) || bankIdEmail
+ if (!bankIdFlowId) {
+ setFormError({ kind: 'bankid', message: t('bankid_failed_description') })
+ return
+ }
+
+ setIsLoading(true)
+
try {
+ // Only the e-mail travels: the session and the fact that this is a
+ // signup are both pinned in the server's flow cookie.
const res = await fetch('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sessionId: bankIdSessionId,
- mode: 'signup',
- email: emailValue,
- }),
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-bankid-flow-id': bankIdFlowId,
+ },
+ body: JSON.stringify({ email: emailValue }),
})
const json = await res.json()
@@ -546,7 +555,9 @@ function RegisterPageContent() {
{inviteEmail ? t('invite_email_hint') : t('bankid_email_hint')}
-
+ {/* Also disabled while Back's /cancel is in flight: submitting
+ then would race the cookie clear (recoverable, but pointless). */}
+
{isLoading ? (
<>
@@ -560,9 +571,35 @@ function RegisterPageContent() {
type="button"
variant="ghost"
className="w-full text-muted-foreground"
- onClick={() => {
- setBankIdUser(null)
- setBankIdSessionId(null)
+ disabled={isLoading || isCancelling}
+ onClick={async () => {
+ // AWAIT the cancel before remounting BankIdAuth. The flow
+ // cookie outlives this component and BankIdAuth probes for a
+ // live flow on mount, so clearing the form first would let it
+ // find the still-completed session. Its own isCancelling
+ // state, not isLoading: isLoading drives the submit button's
+ // "Skapar konto...", and pressing Back should not claim an
+ // account is being created.
+ setIsCancelling(true)
+ try {
+ const res = await fetch('/api/extensions/ext/tic/bankid/cancel', {
+ method: 'POST',
+ headers: bankIdFlowId
+ ? { 'x-bankid-flow-id': bankIdFlowId }
+ : undefined,
+ })
+ if (!res.ok) throw new Error(`cancel failed: ${res.status}`)
+ // Flow cleared server-side; the remounted BankIdAuth will
+ // probe, find nothing, and show the start button.
+ setBankIdUser(null)
+ setBankIdFlowId(null)
+ } catch {
+ // The flow is still live, so resetting the form would just
+ // bounce the user back here. Say so instead of looping.
+ setFormError({ kind: 'bankid', message: t('bankid_cancel_failed') })
+ } finally {
+ setIsCancelling(false)
+ }
}}
>
diff --git a/components/auth/BankIdAuth.tsx b/components/auth/BankIdAuth.tsx
index a1bb9f37..b272dea4 100644
--- a/components/auth/BankIdAuth.tsx
+++ b/components/auth/BankIdAuth.tsx
@@ -2,18 +2,29 @@
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 } from 'lucide-react'
+import { Smartphone, Monitor, AlertTriangle, Loader2 } from 'lucide-react'
-type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account' | 'service_unavailable'
+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
+ * 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
@@ -21,8 +32,16 @@ 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 {
- sessionId: string
+ flowId: string
autoStartToken: string
qrStartToken: string
qrStartSecret: string
@@ -35,7 +54,8 @@ export interface BankIdResult {
error?: 'no_account' | 'already_linked' | 'session_invalid' | 'service_unavailable'
givenName?: string
surname?: string
- sessionId?: string
+ /** Non-secret id that binds this tab to the shared server-held flow. */
+ flowId?: string
}
interface BankIdAuthProps {
@@ -50,74 +70,28 @@ interface BankIdAuthProps {
}
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)
}
-/**
- * sessionStorage key holding an in-flight BankID session across the mobile
- * return-redirect. On iOS the BankID app returns to the SAME Safari tab by
- * reloading it (see launchBankIdApp), which wipes React state, so we stash the
- * session here and resume polling on the next mount.
- */
-const PENDING_KEY = 'bankid:pending'
-/** Ignore a stashed session older than this (BankID orders expire in ~3 min). */
-const PENDING_TTL_MS = 5 * 60 * 1000
-
-interface PendingBankId {
- session: BankIdSession
- mode: string
- ts: number
-}
-
-function persistPending(session: BankIdSession, mode: string): void {
- try {
- sessionStorage.setItem(
- PENDING_KEY,
- JSON.stringify({ session, mode, ts: Date.now() } satisfies PendingBankId)
- )
- } catch {
- // sessionStorage unavailable (private mode / quota): auto-resume just won't
- // fire; the user can still switch back to the tab manually as before.
- }
-}
-
-function readPending(mode: string): PendingBankId | null {
- try {
- const raw = sessionStorage.getItem(PENDING_KEY)
- if (!raw) return null
- const parsed = JSON.parse(raw) as PendingBankId
- if (parsed?.mode !== mode) return null
- if (typeof parsed.ts !== 'number' || Date.now() - parsed.ts > PENDING_TTL_MS) return null
- if (!parsed.session?.sessionId) return null
- return parsed
- } catch {
- return null
- }
-}
-
-function clearPending(): void {
- try {
- sessionStorage.removeItem(PENDING_KEY)
- } catch {
- // ignore
- }
-}
-
/**
* 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). The universal link is
- * tied to the originating tab, so BankID returns the user to it.
+ * 3bc652cc reverted a redirect for exactly that reason).
*
* redirect:
- * • iOS → current URL, so the app navigates this tab back here on success
- * (the resume effect then completes the flow).
+ * • 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.
*/
@@ -134,10 +108,13 @@ function launchBankIdApp(autoStartToken: string): void {
* polling, and result handling.
*/
export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps) {
+ const t = useTranslations('auth')
const [status, setStatus] = useState('idle')
const [session, setSession] = useState(null)
+ const [activeFlowId, setActiveFlowId] = useState(null)
const [hintMessage, setHintMessage] = useState('')
const [errorMessage, setErrorMessage] = useState('')
+ const [launchedApp, setLaunchedApp] = useState(false)
const pollRef = useRef | null>(null)
const abortRef = useRef(null)
const lastStartRef = useRef(0)
@@ -145,17 +122,22 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
onCompleteRef.current = onComplete
const pollFailureCount = useRef(0)
- /** True while a poll response is being processed — prevents overlapping ticks. */
+ /** 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. */
+ /** 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. */
+ /** 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)
@@ -177,10 +159,14 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
[cleanup]
)
- // Poll an in-flight BankID session until it completes, fails, or the service
- // gives up. Extracted from startSession so the resume effect (mobile return)
- // can re-attach to a session that was started before the tab reloaded.
- const beginPolling = useCallback((session: BankIdSession) => {
+ // 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
@@ -192,11 +178,10 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
// link and fails the login intermittently).
if (pollInFlightRef.current || completedRef.current) return
- // Hard cap — a session that never reaches a terminal state (expired
+ // 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()
- clearPending()
setStatus('failed')
setErrorMessage('BankID-sessionen löpte ut. Försök igen.')
return
@@ -204,21 +189,57 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
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' },
- body: JSON.stringify({ sessionId: session.sessionId }),
+ 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
+ // 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()
- clearPending()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
@@ -242,31 +263,34 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
setHintMessage(pollData.message)
}
- // Handle token refresh (order regeneration ~25s)
+ // 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) =>
- prev
- ? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
- : prev
- )
+ setSession((prev) => ({
+ flowId,
+ autoStartToken: prev?.autoStartToken ?? '',
+ qrStartToken: pollData.qrStartToken,
+ qrStartSecret: pollData.qrStartSecret,
+ }))
}
if (pollData.status === 'complete') {
completedRef.current = true
cleanup()
- clearPending()
setStatus('complete')
if (mode === 'login') {
- // For login, call /complete to exchange for Supabase session
+ // 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: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sessionId: session.sessionId,
- mode: 'login',
- }),
+ headers: { [FLOW_ID_HEADER]: flowId },
})
const completeJson = await completeRes.json()
@@ -295,12 +319,14 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
onCompleteRef.current({ error: 'session_invalid' })
}
} else if (mode === 'link') {
- // For link, call /link to associate BankID with current user
+ // 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: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ sessionId: session.sessionId }),
+ headers: { [FLOW_ID_HEADER]: flowId },
})
const linkJson = await linkRes.json()
@@ -314,17 +340,18 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
onCompleteRef.current({ error: 'session_invalid' })
}
} else {
- // For signup, return user data + sessionId so parent can collect email
+ // 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,
- sessionId: session.sessionId,
+ flowId,
})
}
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
completedRef.current = true
cleanup()
- clearPending()
setStatus('failed')
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
}
@@ -333,7 +360,6 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
pollFailureCount.current++
if (pollFailureCount.current >= MAX_POLL_FAILURES) {
cleanup()
- clearPending()
setStatus('service_unavailable')
setErrorMessage('BankID-tjänsten är inte tillgänglig just nu')
onCompleteRef.current({ error: 'service_unavailable' })
@@ -345,20 +371,22 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
}, [cleanup, mode])
const startSession = useCallback(async () => {
- // Collapse double-clicks — one billable TIC session per intent.
+ // Collapse double-clicks: one billable TIC session per intent.
if (startingRef.current) return
startingRef.current = true
const gen = ++startGenRef.current
cleanup()
- clearPending()
+ 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
+ // 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
@@ -368,7 +396,13 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
if (gen !== startGenRef.current) return // cancelled/unmounted while waiting
lastStartRef.current = Date.now()
- const res = await fetch(`${API_BASE}/start`, { method: 'POST' })
+ // 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) {
@@ -385,7 +419,7 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
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
+ // 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')
@@ -396,17 +430,18 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
const { data } = await res.json()
const newSession: BankIdSession = data
setSession(newSession)
+ setActiveFlowId(newSession.flowId)
- // On mobile, open the BankID app on this device.
+ // 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()) {
- // Persist BEFORE launching: on iOS the BankID app returns by reloading
- // THIS tab (universal link → same tab), which wipes in-memory state.
- // The resume effect re-attaches polling on load. See launchBankIdApp.
- persistPending(newSession, mode)
+ setLaunchedApp(true)
launchBankIdApp(newSession.autoStartToken)
}
- beginPolling(newSession)
+ beginPolling(newSession.flowId)
} catch (error) {
if (gen !== startGenRef.current) return
console.error('[bankid] start failed', error)
@@ -417,30 +452,154 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
}
}, [cleanup, mode, beginPolling])
- // After returning from the BankID app on mobile, iOS reloads this tab. Pick up
- // the session we persisted before launching and resume polling so the flow
- // completes without the user having to tap "Logga in med BankID" again.
- useEffect(() => {
- const pending = readPending(mode)
- if (!pending) return
- setSession(pending.session)
+ /**
+ * 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('Slutför BankID-verifieringen...')
- beginPolling(pending.session)
- // Run once on mount: we're recovering state that the return-reload destroyed.
+ 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
- if (session) {
- fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {})
- }
cleanup()
- clearPending()
- setStatus('idle')
setSession(null)
- }, [session, cleanup])
+ // 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 (
+
+
+ {t('bankid_cancelling')}
+
+ )
+ }
+
+ if (status === 'resumable') {
+ return (
+
+
+
+
{t('bankid_resume_title')}
+
+ {/* 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')}
+
+
+
+
+
+ {t('bankid_resume_continue')}
+
+
+ {t('bankid_resume_restart')}
+
+
+
+
+ )
+ }
+
+ if (status === 'complete') {
+ return (
+
+
+ {t('bankid_completing')}
+
+ )
+ }
if (status === 'idle') {
const label = mode === 'login'
@@ -521,22 +680,34 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
qrStartToken={session.qrStartToken}
qrStartSecret={session.qrStartSecret}
/>
-
-
- BankID på den här enheten
-
+ {/* 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 && (
+
+
+ BankID på den här enheten
+
+ )}
>
)}
{isMobile() && (
-
Öppnar BankID-appen...
+ {/* A resumed tab never launched anything, so saying "opening the
+ BankID app" would be a lie followed by silence. */}
+
+ {launchedApp
+ ? 'Öppnar BankID-appen...'
+ : t('bankid_finish_in_app')}
+
)}
@@ -544,6 +715,7 @@ export function BankIdAuth({ mode, onComplete, hero = false }: BankIdAuthProps)
({
createClient: vi.fn(),
}))
-import { collectBankIdResult, requestEnrichment, fetchEnrichmentData } from '../lib/bankid-client'
+import {
+ cancelBankIdSession,
+ collectBankIdResult,
+ pollBankIdSession,
+ requestEnrichment,
+ fetchEnrichmentData,
+ startBankIdAuth,
+} from '../lib/bankid-client'
import { createServiceClient } from '@/lib/supabase/server'
import { ticExtension } from '../index'
+import {
+ BANKID_FLOW_COOKIE,
+ BANKID_FLOW_ID_HEADER,
+ signBankIdFlow,
+ verifyBankIdFlow,
+ type BankIdFlowMode,
+} from '../lib/bankid-flow-cookie'
const TEST_KEY = 'a'.repeat(64)
+const TEST_FLOW_ID = 'flow-1'
+
+/**
+ * The session id and the mode now arrive in a signed HttpOnly cookie rather
+ * than the request body, so nothing a caller can name decides which session
+ * gets completed, or as which kind of flow.
+ */
+async function flowCookie(
+ mode: BankIdFlowMode,
+ sessionId = 'test-session',
+ userId = 'user-1',
+): Promise> {
+ const value = await signBankIdFlow({
+ version: 1,
+ sessionId,
+ flowId: TEST_FLOW_ID,
+ mode,
+ // A link flow is owned by the user who opened it; login/signup have none.
+ userId: mode === 'link' ? userId : undefined,
+ startedAt: Date.now(),
+ expiresAt: Date.now() + 60_000,
+ })
+ return {
+ cookie: `${BANKID_FLOW_COOKIE}=${encodeURIComponent(value)}`,
+ [BANKID_FLOW_ID_HEADER]: TEST_FLOW_ID,
+ }
+}
function findCompleteHandler() {
const route = ticExtension.apiRoutes!.find(
@@ -45,7 +86,14 @@ function makeSession(overrides: Partial<{ status: string; user: unknown }> = {})
type QueuedResult = { data?: unknown; error?: unknown }
-function mockServiceClient(fromResults: QueuedResult[]) {
+function mockServiceClient(
+ fromResults: QueuedResult[],
+ // The single-use claim against bankid_consumed_sessions. Routed by table name
+ // rather than taken from the queue, so each test's queue keeps describing
+ // only the lookups it cares about. `{ error: { code: '23505' } }` is the
+ // other tab having claimed the session first.
+ consumed: QueuedResult = { error: null },
+) {
const queue = [...fromResults]
const chain = (): unknown => {
@@ -82,7 +130,9 @@ function mockServiceClient(fromResults: QueuedResult[]) {
}
const client = {
- from: vi.fn().mockImplementation(() => chain()),
+ from: vi.fn().mockImplementation((table: string) =>
+ table === 'bankid_consumed_sessions' ? chain2(consumed) : chain()
+ ),
auth: { admin },
}
@@ -117,7 +167,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'victim@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'victim@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string; data?: unknown }>(
await findCompleteHandler()(req)
@@ -149,7 +200,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(req)
@@ -171,7 +223,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; type?: string; isNewUser?: boolean }
@@ -203,7 +256,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'x@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'x@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(req)
@@ -217,7 +271,7 @@ describe('POST /bankid/complete', () => {
})
})
- describe('signup mode — rollback on partial failure', () => {
+ describe('signup mode: rollback on partial failure', () => {
// A half-created account strands the user: retrying signup hits
// account_exists/already_linked, but the account only has a random
// password they never saw. Every failure after createUser must delete
@@ -232,7 +286,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(req)
@@ -258,7 +313,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(req)
@@ -281,7 +337,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(req)
@@ -302,7 +359,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'fresh@example.com' },
})
const { status } = await parseJsonResponse(await findCompleteHandler()(req))
@@ -320,7 +378,7 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'login' },
+ headers: await flowCookie('login'),
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(req)
@@ -395,7 +453,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'fresh@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; isNewUser?: boolean }
@@ -434,7 +493,8 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup', email: 'x@example.com' },
+ headers: await flowCookie('signup'),
+ body: { email: 'x@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findCompleteHandler()(req)
@@ -449,7 +509,7 @@ describe('POST /bankid/complete', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
- body: { sessionId: 'test-session', mode: 'signup' },
+ headers: await flowCookie('signup'),
})
const { status } = await parseJsonResponse(await findCompleteHandler()(req))
@@ -458,4 +518,516 @@ describe('POST /bankid/complete', () => {
expect(collectBankIdResult).not.toHaveBeenCalled()
})
})
+
+ describe('the flow cookie is the only thing that names a session', () => {
+ /** Did the handler expire the flow cookie on this response? */
+ function clearedFlow(response: Response): boolean {
+ return response.headers
+ .getSetCookie()
+ .some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c))
+ }
+
+ it('refuses completion from a stale tab after the shared cookie was replaced', async () => {
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+ mockServiceClient([])
+ const headers = await flowCookie('signup')
+ headers[BANKID_FLOW_ID_HEADER] = 'older-flow'
+
+ const { status } = await parseJsonResponse(
+ await findCompleteHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/complete', {
+ method: 'POST',
+ headers,
+ body: { email: 'fresh@example.com' },
+ })
+ )
+ )
+
+ expect(status).toBe(400)
+ expect(collectBankIdResult).not.toHaveBeenCalled()
+ })
+
+ it('ignores a sessionId and mode supplied in the body', async () => {
+ // The old contract took both from the body, which made /complete a
+ // bearer endpoint: anyone who had seen a session id could complete it,
+ // in whatever mode suited them.
+ mockServiceClient([])
+
+ const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
+ method: 'POST',
+ body: { sessionId: 'attacker-session', mode: 'login' },
+ })
+ const { status, body } = await parseJsonResponse<{ error?: string }>(
+ await findCompleteHandler()(req)
+ )
+
+ expect(status).toBe(400)
+ expect(body.error).toBe('session_invalid')
+ expect(collectBankIdResult).not.toHaveBeenCalled()
+ })
+
+ it('refuses to complete a session that was opened as a link flow', async () => {
+ // Linking happens on the authenticated /bankid/link route. Completing a
+ // link session here would create or sign in an account off a session
+ // opened for something else entirely.
+ mockServiceClient([])
+
+ const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
+ method: 'POST',
+ headers: await flowCookie('link'),
+ body: { email: 'attacker@example.com' },
+ })
+ const { status, body } = await parseJsonResponse<{ error?: string }>(
+ await findCompleteHandler()(req)
+ )
+
+ expect(status).toBe(400)
+ expect(body.error).toBe('session_invalid')
+ expect(collectBankIdResult).not.toHaveBeenCalled()
+ })
+
+ it('spends the flow on success, so a second tab cannot mint a rival magic link', async () => {
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+ const { admin } = mockServiceClient([
+ { data: null }, // pnr lookup → not linked
+ { error: null }, // bankid_identities insert OK
+ ])
+
+ const response = await findCompleteHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/complete', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ body: { email: 'fresh@example.com' },
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(admin.generateLink).toHaveBeenCalled()
+ // Without this, two tabs that both saw 'complete' would each mint a
+ // magic link and the second would invalidate the first.
+ expect(clearedFlow(response)).toBe(true)
+ })
+
+ it('refuses a login whose session another tab already spent', async () => {
+ // The Set-Cookie clear does NOT make this single-use: two requests that
+ // both already carried the cookie both reach here. The unique index on
+ // bankid_consumed_sessions is what stops the second from minting a rival
+ // magic link that would invalidate the first tab's.
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+ const { admin } = mockServiceClient(
+ [{ data: { user_id: 'existing-user' } }], // pnr is linked
+ { error: { code: '23505', message: 'duplicate key' } },
+ )
+
+ const { status, body } = await parseJsonResponse<{ error?: string }>(
+ await findCompleteHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/complete', {
+ method: 'POST',
+ headers: await flowCookie('login'),
+ })
+ )
+ )
+
+ expect(status).toBe(400)
+ expect(body.error).toBe('session_invalid')
+ expect(admin.generateLink).not.toHaveBeenCalled()
+ })
+
+ it('rolls the new account back when a signup loses the same race', async () => {
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+ const { admin } = mockServiceClient(
+ [{ data: null }, { error: null }],
+ { error: { code: '23505', message: 'duplicate key' } },
+ )
+
+ const { status } = await parseJsonResponse(
+ await findCompleteHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/complete', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ body: { email: 'fresh@example.com' },
+ })
+ )
+ )
+
+ expect(status).toBe(400)
+ expect(admin.generateLink).not.toHaveBeenCalled()
+ // A half-created account would strand the address: the retry would hit
+ // account_exists on an account whose password the user never saw.
+ expect(admin.deleteUser).toHaveBeenCalledWith('new-user-uuid')
+ })
+
+ it('fails closed when the single-use claim errors for any other reason', async () => {
+ // Minting a second magic link is worse than asking the user to
+ // authenticate again, so an unreachable table must not be waved through.
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+ const { admin } = mockServiceClient(
+ [{ data: { user_id: 'existing-user' } }],
+ { error: { code: '42P01', message: 'relation does not exist' } },
+ )
+
+ const { status } = await parseJsonResponse(
+ await findCompleteHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/complete', {
+ method: 'POST',
+ headers: await flowCookie('login'),
+ })
+ )
+ )
+
+ expect(status).toBe(400)
+ expect(admin.generateLink).not.toHaveBeenCalled()
+ })
+
+ it('keeps the flow alive when the e-mail is already taken, so it can be corrected', async () => {
+ // account_exists consumes nothing and is usually a typo: forcing a
+ // second BankID round trip to fix an address would be gratuitous.
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+ const { admin } = mockServiceClient([{ data: null }])
+ admin.createUser.mockResolvedValueOnce({
+ data: { user: null },
+ error: { status: 422, code: 'email_exists', message: 'already registered' },
+ } as never)
+
+ const response = await findCompleteHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/complete', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ body: { email: 'taken@example.com' },
+ })
+ )
+
+ expect(response.status).toBe(409)
+ expect(clearedFlow(response)).toBe(false)
+ })
+ })
+})
+
+describe('POST /bankid/start', () => {
+ function findStartHandler() {
+ const route = ticExtension.apiRoutes!.find(
+ (r) => r.method === 'POST' && r.path === '/bankid/start'
+ )
+ if (!route) throw new Error('POST /bankid/start route not found')
+ return route.handler
+ }
+
+ it('rejects a request that does not name a flow', async () => {
+ const { status } = await parseJsonResponse(
+ await findStartHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/start', {
+ method: 'POST',
+ body: { mode: 'admin' },
+ })
+ )
+ )
+ expect(status).toBe(400)
+ expect(startBankIdAuth).not.toHaveBeenCalled()
+ })
+
+ it('withholds the session id from the response and puts it in the cookie', async () => {
+ vi.mocked(startBankIdAuth).mockResolvedValue({
+ sessionId: 'secret-session',
+ autoStartToken: 'ast',
+ qrStartToken: 'qrt',
+ qrStartSecret: 'qrs',
+ } as never)
+
+ const response = await findStartHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/start', {
+ method: 'POST',
+ body: { mode: 'signup' },
+ })
+ )
+ const payload = await response.clone().text()
+ const parsed = JSON.parse(payload) as { data: { flowId: string } }
+
+ // The id is a bearer credential for a personnummer and for a session.
+ // The browser gets the autostart/QR tokens, which identify nobody.
+ expect(payload).not.toContain('secret-session')
+ expect(payload).toContain('ast')
+ expect(parsed.data.flowId).toBeTruthy()
+
+ const flowCookieHeader = response.headers
+ .getSetCookie()
+ .find((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`))
+ expect(flowCookieHeader).toMatch(/HttpOnly/i)
+
+ const [, value] = /^[^=]+=([^;]*)/.exec(flowCookieHeader!)!
+ const flow = await verifyBankIdFlow(decodeURIComponent(value))
+ expect(flow).toMatchObject({
+ sessionId: 'secret-session',
+ mode: 'signup',
+ })
+ expect(flow?.flowId).toBe(parsed.data.flowId)
+ })
+})
+
+describe('POST /bankid/poll', () => {
+ function findPollHandler() {
+ const route = ticExtension.apiRoutes!.find(
+ (r) => r.method === 'POST' && r.path === '/bankid/poll'
+ )
+ if (!route) throw new Error('POST /bankid/poll route not found')
+ return route.handler
+ }
+
+ it('answers 404 when the browser holds no flow, instead of polling a named session', async () => {
+ const { status } = await parseJsonResponse(
+ await findPollHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/poll', {
+ method: 'POST',
+ body: { sessionId: 'attacker-session' },
+ })
+ )
+ )
+
+ expect(status).toBe(404)
+ expect(pollBankIdSession).not.toHaveBeenCalled()
+ })
+
+ it('never returns the personnummer', async () => {
+ // This route is skipAuth, so whatever it returns is readable by whoever
+ // holds the flow cookie. The UI only ever needed the names.
+ vi.mocked(pollBankIdSession).mockResolvedValue({
+ status: 'complete',
+ user: {
+ personalNumber: '199001011234',
+ givenName: 'Anna',
+ surname: 'Andersson',
+ name: 'Anna Andersson',
+ },
+ } as never)
+
+ const response = await findPollHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/poll', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ body: { mode: 'signup' },
+ })
+ )
+ const payload = await response.clone().text()
+ const { body } = await parseJsonResponse<{ data: { user?: Record } }>(response)
+
+ expect(payload).not.toContain('199001011234')
+ expect(body.data.user).toEqual({ givenName: 'Anna', surname: 'Andersson' })
+ })
+
+ it('withholds the holder name from a probe, but gives it to the active poll', async () => {
+ // The mount probe runs before the person here has confirmed the flow is
+ // theirs, so the name must not reach them (it would identify a stranger on
+ // a shared machine). The active poll, reached only after ownership/confirm,
+ // needs the name for the signup e-mail step.
+ vi.mocked(pollBankIdSession).mockResolvedValue({
+ status: 'complete',
+ user: { givenName: 'Anna', surname: 'Andersson', personalNumber: 'x' },
+ } as never)
+
+ const probe = await parseJsonResponse<{ data: { flowId?: string; user?: unknown } }>(
+ await findPollHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/poll', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ body: { mode: 'signup', probe: true },
+ })
+ )
+ )
+ expect(probe.body.data.flowId).toBe(TEST_FLOW_ID)
+ expect(probe.body.data.user).toBeUndefined()
+
+ const active = await parseJsonResponse<{ data: { user?: unknown } }>(
+ await findPollHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/poll', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ body: { mode: 'signup' },
+ })
+ )
+ )
+ expect(active.body.data.user).toEqual({ givenName: 'Anna', surname: 'Andersson' })
+ })
+
+ it('refuses to poll a flow whose mode does not match the panel asking', async () => {
+ // A login session started on /login must not be pollable by the signup
+ // panel: the client would render the signup e-mail step and /complete
+ // would then read mode 'login' off the cookie, either signing the user in
+ // from the "Skapa konto" form or burning the identification on no_account.
+ const { status, body } = await parseJsonResponse<{ error?: string }>(
+ await findPollHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/poll', {
+ method: 'POST',
+ headers: await flowCookie('login'),
+ body: { mode: 'signup' },
+ })
+ )
+ )
+
+ expect(status).toBe(404)
+ expect(body.error).toBe('no_session')
+ expect(pollBankIdSession).not.toHaveBeenCalled()
+ })
+
+ it('polls when both the panel mode and tab flow id match', async () => {
+ vi.mocked(pollBankIdSession).mockResolvedValue({ status: 'pending' } as never)
+
+ const { status } = await parseJsonResponse(
+ await findPollHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/poll', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ body: { mode: 'signup' },
+ })
+ )
+ )
+ expect(status).toBe(200)
+ expect(pollBankIdSession).toHaveBeenCalledOnce()
+ })
+
+ it('refuses a stale tab after a newer same-mode flow replaced the shared cookie', async () => {
+ vi.mocked(pollBankIdSession).mockResolvedValue({ status: 'complete' } as never)
+ const headers = await flowCookie('signup')
+ headers[BANKID_FLOW_ID_HEADER] = 'older-flow'
+
+ const { status, body } = await parseJsonResponse<{ error?: string }>(
+ await findPollHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/poll', {
+ method: 'POST',
+ headers,
+ body: { mode: 'signup' },
+ })
+ )
+ )
+
+ expect(status).toBe(404)
+ expect(body.error).toBe('no_session')
+ expect(pollBankIdSession).not.toHaveBeenCalled()
+ })
+
+ it('does NOT clear the cookie when TIC has forgotten the session', async () => {
+ // A clearing Set-Cookie cannot be aimed at one flow, so a slow response
+ // about a dead session would delete whatever flow is in the jar by the
+ // time it lands, including one the user just started in another tab.
+ const { TICAPIError } = await import('../lib/tic-types')
+ vi.mocked(pollBankIdSession).mockRejectedValueOnce(
+ new TICAPIError('gone', 404)
+ )
+
+ const response = await findPollHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/poll', {
+ method: 'POST',
+ headers: await flowCookie('login'),
+ body: { mode: 'login' },
+ })
+ )
+ const { status, body } = await parseJsonResponse<{ error?: string }>(response)
+
+ expect(status).toBe(404)
+ expect(body.error).toBe('no_session')
+ const cleared = response.headers
+ .getSetCookie()
+ .some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c))
+ expect(cleared).toBe(false)
+ })
+
+ it('extends the window to the verified budget once identification completes', async () => {
+ // The signup e-mail step is a person typing; the order window (300s) is
+ // too short for it, so completion re-issues at the longer budget.
+ vi.mocked(pollBankIdSession).mockResolvedValue({
+ status: 'complete',
+ user: { givenName: 'Anna', surname: 'Andersson', personalNumber: 'x' },
+ } as never)
+
+ const response = await findPollHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/poll', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ body: { mode: 'signup' },
+ })
+ )
+
+ // A fresh signed cookie is issued (Max-Age well past the 300s order window).
+ const reissued = response.headers
+ .getSetCookie()
+ .find((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`))
+ expect(reissued).toBeDefined()
+ const maxAge = Number(/Max-Age=(\d+)/i.exec(reissued!)?.[1])
+ expect(maxAge).toBeGreaterThan(300)
+ })
+})
+
+describe('POST /bankid/cancel', () => {
+ function findCancelHandler() {
+ const route = ticExtension.apiRoutes!.find(
+ (r) => r.method === 'POST' && r.path === '/bankid/cancel'
+ )
+ if (!route) throw new Error('POST /bankid/cancel route not found')
+ return route.handler
+ }
+
+ function clearsFlow(response: Response): boolean {
+ return response.headers
+ .getSetCookie()
+ .some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c))
+ }
+
+ it('cancels the flow this browser holds, with no id to aim it', async () => {
+ const response = await findCancelHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/cancel', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ // A named session in the body must be ignored: the old DELETE route
+ // took one, which let a caller cancel a session they merely knew of.
+ body: { sessionId: 'someone-elses-session' },
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(clearsFlow(response)).toBe(true)
+ expect(cancelBankIdSession).toHaveBeenCalledWith('test-session')
+ })
+
+ it('still clears the cookie when TIC cannot be reached', async () => {
+ // Otherwise pressing Avbryt during a TIC outage leaves a flow in the
+ // browser that the next page load resumes.
+ vi.mocked(cancelBankIdSession).mockRejectedValueOnce(new Error('network down'))
+
+ const response = await findCancelHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/cancel', {
+ method: 'POST',
+ headers: await flowCookie('signup'),
+ })
+ )
+
+ expect(response.status).toBe(200)
+ expect(clearsFlow(response)).toBe(true)
+ })
+
+ it('does not cancel or clear a newer flow from a stale tab', async () => {
+ const headers = await flowCookie('signup')
+ headers[BANKID_FLOW_ID_HEADER] = 'older-flow'
+
+ const response = await findCancelHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/cancel', {
+ method: 'POST',
+ headers,
+ })
+ )
+ const { body } = await parseJsonResponse<{
+ data?: { cancelled?: boolean; replaced?: boolean }
+ }>(response.clone())
+
+ expect(response.status).toBe(200)
+ expect(body.data).toEqual({ cancelled: false, replaced: true })
+ expect(clearsFlow(response)).toBe(false)
+ expect(cancelBankIdSession).not.toHaveBeenCalled()
+ })
+
+ it('is a no-op that still succeeds when there is no flow', async () => {
+ const response = await findCancelHandler()(
+ createMockRequest('/api/extensions/ext/tic/bankid/cancel', { method: 'POST' })
+ )
+
+ expect(response.status).toBe(200)
+ expect(cancelBankIdSession).not.toHaveBeenCalled()
+ })
})
diff --git a/extensions/general/tic/__tests__/bankid-flow-cookie.test.ts b/extensions/general/tic/__tests__/bankid-flow-cookie.test.ts
new file mode 100644
index 00000000..43143b89
--- /dev/null
+++ b/extensions/general/tic/__tests__/bankid-flow-cookie.test.ts
@@ -0,0 +1,268 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { NextResponse } from 'next/server'
+import {
+ BANKID_FLOW_COOKIE,
+ FLOW_VERIFIED_WINDOW_SECONDS,
+ FLOW_WINDOW_SECONDS,
+ clearBankIdFlowCookies,
+ isBankIdFlowMode,
+ readBankIdFlow,
+ setBankIdFlowCookies,
+ signBankIdFlow,
+ verifyBankIdFlow,
+ type BankIdFlowState,
+} from '../lib/bankid-flow-cookie'
+
+const TEST_KEY = 'a'.repeat(64)
+const T0 = 1_700_000_000_000
+
+const STATE: BankIdFlowState = {
+ version: 1,
+ sessionId: 'sess-1',
+ flowId: 'flow-1',
+ mode: 'signup',
+ startedAt: T0,
+ expiresAt: T0 + FLOW_WINDOW_SECONDS * 1000,
+}
+
+beforeEach(() => {
+ vi.stubEnv('BANKID_ENCRYPTION_KEY', TEST_KEY)
+})
+
+afterEach(() => {
+ vi.unstubAllEnvs()
+})
+
+/** Parse the Set-Cookie headers a handler attached to its response. */
+function setCookies(response: NextResponse): Map {
+ const result = new Map()
+ for (const header of response.headers.getSetCookie()) {
+ const [pair, ...attrs] = header.split(';')
+ const separator = pair.indexOf('=')
+ result.set(pair.slice(0, separator).trim(), {
+ value: decodeURIComponent(pair.slice(separator + 1)),
+ attrs: attrs.join(';'),
+ })
+ }
+ return result
+}
+
+function cookieHeader(value: string, name = BANKID_FLOW_COOKIE): Request {
+ return new Request('http://localhost:3000/x', {
+ headers: { cookie: `${name}=${encodeURIComponent(value)}` },
+ })
+}
+
+describe('sign / verify', () => {
+ it('round-trips a flow', async () => {
+ const signed = await signBankIdFlow(STATE)
+ expect(await verifyBankIdFlow(signed, process.env, T0 + 1000)).toEqual(STATE)
+ })
+
+ it('carries the owner of a link flow', async () => {
+ const link: BankIdFlowState = { ...STATE, mode: 'link', userId: 'user-1' }
+ expect(await verifyBankIdFlow(await signBankIdFlow(link), process.env, T0)).toEqual(link)
+ })
+
+ it('rejects a link flow with no owner', async () => {
+ // An unowned link flow is the shape that binds one person's personnummer
+ // to the next person to use a shared browser.
+ const signed = await signBankIdFlow({ ...STATE, mode: 'link' })
+ expect(await verifyBankIdFlow(signed, process.env, T0)).toBeNull()
+ })
+
+ it('does not put the session id in the clear', async () => {
+ const signed = await signBankIdFlow(STATE)
+ expect(signed).not.toContain('sess-1')
+ expect(signed.split('.')).toHaveLength(2)
+ })
+
+ it('rejects a tampered payload', async () => {
+ const signed = await signBankIdFlow(STATE)
+ const [, signature] = signed.split('.')
+ const forged = Buffer.from(
+ JSON.stringify({ ...STATE, sessionId: 'attacker-session' }),
+ ).toString('base64url')
+
+ expect(await verifyBankIdFlow(`${forged}.${signature}`, process.env, T0)).toBeNull()
+ })
+
+ it('rejects an unsigned cookie a script could plant', async () => {
+ const payload = Buffer.from(JSON.stringify(STATE)).toString('base64url')
+ expect(await verifyBankIdFlow(payload, process.env, T0)).toBeNull()
+ expect(await verifyBankIdFlow(`${payload}.`, process.env, T0)).toBeNull()
+ expect(await verifyBankIdFlow(`${payload}.x`, process.env, T0)).toBeNull()
+ })
+
+ it('rejects a cookie signed with a different secret', async () => {
+ const signed = await signBankIdFlow(STATE, { BANKID_ENCRYPTION_KEY: 'b'.repeat(64) })
+ expect(await verifyBankIdFlow(signed, process.env, T0)).toBeNull()
+ })
+
+ it('rejects malformed and empty values', async () => {
+ expect(await verifyBankIdFlow(undefined, process.env, T0)).toBeNull()
+ expect(await verifyBankIdFlow('', process.env, T0)).toBeNull()
+ expect(await verifyBankIdFlow('not.a.cookie', process.env, T0)).toBeNull()
+ expect(await verifyBankIdFlow('....', process.env, T0)).toBeNull()
+ })
+
+ it('expires on the server clock, not the browser Max-Age', async () => {
+ const signed = await signBankIdFlow(STATE)
+ expect(await verifyBankIdFlow(signed, process.env, STATE.expiresAt)).toEqual(STATE)
+ expect(await verifyBankIdFlow(signed, process.env, STATE.expiresAt + 1)).toBeNull()
+ })
+
+ it('refuses an expiry further out than the longest window this server issues', async () => {
+ const signed = await signBankIdFlow({ ...STATE, expiresAt: T0 + 24 * 3600 * 1000 })
+ expect(await verifyBankIdFlow(signed, process.env, T0)).toBeNull()
+ })
+
+ it('accepts the verified window, which the e-mail step needs', async () => {
+ const verified = { ...STATE, expiresAt: T0 + FLOW_VERIFIED_WINDOW_SECONDS * 1000 }
+ expect(await verifyBankIdFlow(await signBankIdFlow(verified), process.env, T0)).toEqual(verified)
+ })
+
+ it('caps the whole flow from startedAt, so re-issuing cannot extend it forever', async () => {
+ // /poll pushes expiresAt out when it sees a completed identification.
+ // Without a cap measured from the original start, polling in a loop would
+ // keep a usable identification alive for as long as TIC retains it.
+ // MAX_TOTAL_LIFE is FLOW_WINDOW + FLOW_VERIFIED_WINDOW from startedAt.
+ const cap = (FLOW_WINDOW_SECONDS + FLOW_VERIFIED_WINDOW_SECONDS) * 1000
+ // A cookie re-issued near the cap is still honoured up to it...
+ const late = { ...STATE, expiresAt: T0 + cap + 60_000 }
+ const signed = await signBankIdFlow(late)
+ expect(await verifyBankIdFlow(signed, process.env, T0 + cap)).toEqual(late)
+ // ...and dies the moment the flow as a whole is older than the cap, no
+ // matter how far out the latest re-issue pushed expiresAt.
+ expect(await verifyBankIdFlow(signed, process.env, T0 + cap + 1)).toBeNull()
+ })
+
+ it('rejects a startedAt in the future, which would never age out', async () => {
+ const signed = await signBankIdFlow({ ...STATE, startedAt: T0 + 60_000 })
+ expect(await verifyBankIdFlow(signed, process.env, T0)).toBeNull()
+ })
+
+ it('rejects an unknown mode', async () => {
+ const signed = await signBankIdFlow({ ...STATE, mode: 'admin' as never })
+ expect(await verifyBankIdFlow(signed, process.env, T0)).toBeNull()
+ })
+
+ it('rejects a flow with no tab-binding id', async () => {
+ const signed = await signBankIdFlow({ ...STATE, flowId: undefined as never })
+ expect(await verifyBankIdFlow(signed, process.env, T0)).toBeNull()
+ })
+
+ it('rejects an unknown version, so a future format cannot be replayed as this one', async () => {
+ const signed = await signBankIdFlow({ ...STATE, version: 2 as never })
+ expect(await verifyBankIdFlow(signed, process.env, T0)).toBeNull()
+ })
+
+ it('refuses to run without a signing secret rather than falling back to unsigned', async () => {
+ const empty = {}
+ await expect(signBankIdFlow(STATE, empty)).rejects.toThrow(/requires BANKID_ENCRYPTION_KEY/)
+ expect(await verifyBankIdFlow(await signBankIdFlow(STATE), empty, T0)).toBeNull()
+ })
+})
+
+describe('isBankIdFlowMode', () => {
+ it('accepts exactly the three flows', () => {
+ expect(isBankIdFlowMode('login')).toBe(true)
+ expect(isBankIdFlowMode('signup')).toBe(true)
+ expect(isBankIdFlowMode('link')).toBe(true)
+ expect(isBankIdFlowMode('unlink')).toBe(false)
+ expect(isBankIdFlowMode('')).toBe(false)
+ expect(isBankIdFlowMode(undefined)).toBe(false)
+ })
+})
+
+describe('readBankIdFlow', () => {
+ it('reads its own cookie out of a header carrying others', async () => {
+ const signed = await signBankIdFlow({ ...STATE, startedAt: Date.now(), expiresAt: Date.now() + 60_000 })
+ const request = new Request('http://localhost:3000/x', {
+ headers: {
+ cookie: `sb-access-token=abc; ${BANKID_FLOW_COOKIE}=${encodeURIComponent(signed)}; other=1`,
+ },
+ })
+ expect((await readBankIdFlow(request))?.sessionId).toBe('sess-1')
+ })
+
+ it('fails closed when two cookies of this name arrive', async () => {
+ // The shadowing attack: a script plants the same name at a longer path, and
+ // RFC 6265 sends longer paths FIRST. Taking the first match would use the
+ // planted one. The __Host- prefix should make this unreachable; being wrong
+ // about that must not silently hand the flow to the attacker's cookie.
+ const mine = await signBankIdFlow({ ...STATE, startedAt: Date.now(), expiresAt: Date.now() + 60_000 })
+ const planted = await signBankIdFlow({
+ ...STATE,
+ sessionId: 'attacker-session',
+ startedAt: Date.now(),
+ expiresAt: Date.now() + 60_000,
+ })
+ const request = new Request('http://localhost:3000/x', {
+ headers: {
+ cookie:
+ `${BANKID_FLOW_COOKIE}=${encodeURIComponent(planted)}; ` +
+ `${BANKID_FLOW_COOKIE}=${encodeURIComponent(mine)}`,
+ },
+ })
+
+ expect(await readBankIdFlow(request)).toBeNull()
+ })
+
+ it('returns null with no cookie header and for a foreign cookie name', async () => {
+ expect(await readBankIdFlow(new Request('http://localhost:3000/x'))).toBeNull()
+ const signed = await signBankIdFlow({ ...STATE, startedAt: Date.now(), expiresAt: Date.now() + 60_000 })
+ expect(await readBankIdFlow(cookieHeader(signed, 'accounted-bankid-active'))).toBeNull()
+ })
+})
+
+describe('cookie attributes', () => {
+ it('is a __Host- cookie: HttpOnly, Secure, Lax, Path=/, no Domain', async () => {
+ const response = NextResponse.json({})
+ const expiresAt = Date.now() + FLOW_WINDOW_SECONDS * 1000
+ await setBankIdFlowCookies(response, { ...STATE, startedAt: Date.now(), expiresAt })
+
+ const flow = setCookies(response).get(BANKID_FLOW_COOKIE)!
+ expect(BANKID_FLOW_COOKIE.startsWith('__Host-')).toBe(true)
+ expect(flow.attrs).toMatch(/HttpOnly/i)
+ // Unconditional: __Host- requires it, and BankID is disabled on self-hosted
+ // so there is no plain-http deployment to accommodate.
+ expect(flow.attrs).toMatch(/Secure/i)
+ expect(flow.attrs).toMatch(/Path=\/(;|$)/i)
+ expect(flow.attrs).not.toMatch(/Domain=/i)
+ // Lax, not Strict: the BankID app returns the user by a top-level
+ // navigation from outside the site, and Strict would drop the cookie there.
+ expect(flow.attrs).toMatch(/SameSite=lax/i)
+ expect(flow.attrs).not.toMatch(/SameSite=strict/i)
+ })
+
+ it('mirrors the signed expiry in Max-Age', async () => {
+ const response = NextResponse.json({})
+ const now = Date.now()
+ await setBankIdFlowCookies(response, { ...STATE, startedAt: now, expiresAt: now + 120_000 }, process.env, now)
+
+ expect(setCookies(response).get(BANKID_FLOW_COOKIE)!.attrs).toMatch(/Max-Age=120/i)
+ })
+
+ it('never emits a non-positive Max-Age for an almost-expired flow', async () => {
+ // Max-Age=0 would mean "delete", which would drop a flow that is still
+ // valid for a few hundred milliseconds.
+ const response = NextResponse.json({})
+ const now = Date.now()
+ await setBankIdFlowCookies(response, { ...STATE, startedAt: now, expiresAt: now + 100 }, process.env, now)
+
+ expect(setCookies(response).get(BANKID_FLOW_COOKIE)!.attrs).toMatch(/Max-Age=1(;|$)/i)
+ })
+
+ it('clears with the same name and attributes it set', () => {
+ const response = NextResponse.json({})
+ clearBankIdFlowCookies(response)
+
+ const flow = setCookies(response).get(BANKID_FLOW_COOKIE)!
+ // A deletion that differs on path or attributes leaves the real cookie.
+ expect(flow.attrs).toMatch(/Path=\/(;|$)/i)
+ expect(flow.attrs).toMatch(/HttpOnly/i)
+ expect(flow.attrs).toMatch(/Secure/i)
+ expect(flow.attrs).toMatch(/Max-Age=0/i)
+ })
+})
diff --git a/extensions/general/tic/__tests__/bankid-unlink.test.ts b/extensions/general/tic/__tests__/bankid-unlink.test.ts
index 4b2aabdf..c9a4b758 100644
--- a/extensions/general/tic/__tests__/bankid-unlink.test.ts
+++ b/extensions/general/tic/__tests__/bankid-unlink.test.ts
@@ -24,8 +24,37 @@ import { collectBankIdResult } from '../lib/bankid-client'
import { createServiceClient } from '@/lib/supabase/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { ticExtension } from '../index'
+import {
+ BANKID_FLOW_COOKIE,
+ BANKID_FLOW_ID_HEADER,
+ signBankIdFlow,
+ type BankIdFlowMode,
+} from '../lib/bankid-flow-cookie'
const TEST_KEY = 'a'.repeat(64)
+const TEST_FLOW_ID = 'flow-1'
+
+/** Linking reads its session from the signed flow cookie, not the body. */
+async function flowCookie(
+ mode: BankIdFlowMode,
+ sessionId = 'test-session',
+ userId = 'user-1',
+): Promise> {
+ const value = await signBankIdFlow({
+ version: 1,
+ sessionId,
+ flowId: TEST_FLOW_ID,
+ mode,
+ // A link flow is owned by the user who opened it; login/signup have none.
+ userId: mode === 'link' ? userId : undefined,
+ startedAt: Date.now(),
+ expiresAt: Date.now() + 60_000,
+ })
+ return {
+ cookie: `${BANKID_FLOW_COOKIE}=${encodeURIComponent(value)}`,
+ [BANKID_FLOW_ID_HEADER]: TEST_FLOW_ID,
+ }
+}
function findRoute(method: string, path: string) {
const route = ticExtension.apiRoutes!.find((r) => r.method === method && r.path === path)
@@ -56,7 +85,12 @@ function mockUnauthenticated() {
type QueuedResult = { data?: unknown; error?: unknown }
/** Minimal chainable service-client mock (same pattern as bankid-complete.test.ts). */
-function mockServiceClient(fromResults: QueuedResult[], appMetadata: Record) {
+function mockServiceClient(
+ fromResults: QueuedResult[],
+ appMetadata: Record,
+ // Single-use claim; `{ error: { code: '23505' } }` means another tab won.
+ consumed: QueuedResult = { error: null },
+) {
const queue = [...fromResults]
const chain = (): unknown => {
@@ -87,7 +121,9 @@ function mockServiceClient(fromResults: QueuedResult[], appMetadata: Record chain()),
+ from: vi.fn().mockImplementation((table: string) =>
+ table === 'bankid_consumed_sessions' ? chain2(consumed) : chain()
+ ),
auth: { admin },
}
@@ -170,6 +206,32 @@ describe('POST /bankid/unlink', () => {
})
})
+describe('POST /bankid/start, link mode', () => {
+ it('requires a signed-in caller, so a link flow always has an owner', async () => {
+ // An anonymous link flow would have no userId to check at /bankid/link,
+ // which is what lets an abandoned flow bind to the next person to sign in.
+ mockUnauthenticated()
+ const req = createMockRequest('/api/extensions/ext/tic/bankid/start', {
+ method: 'POST',
+ body: { mode: 'link' },
+ })
+ const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/start')(req))
+
+ expect(status).toBe(401)
+ })
+
+ it('does not require auth for login or signup, which have no user yet', async () => {
+ mockUnauthenticated()
+ const req = createMockRequest('/api/extensions/ext/tic/bankid/start', {
+ method: 'POST',
+ body: { mode: 'login' },
+ })
+ const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/start')(req))
+
+ expect(status).not.toBe(401)
+ })
+})
+
describe('POST /bankid/link', () => {
function makeSession() {
return {
@@ -189,13 +251,13 @@ describe('POST /bankid/link', () => {
mockServiceClient([], {})
const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
method: 'POST',
- body: { sessionId: 'test-session' },
+ headers: await flowCookie('link'),
})
const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/link')(req))
expect(status).toBe(401)
})
- it('returns 400 when sessionId is missing', async () => {
+ it('returns 400 when the browser holds no flow', async () => {
mockAuthenticated()
mockServiceClient([], {})
const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
@@ -206,6 +268,107 @@ describe('POST /bankid/link', () => {
expect(status).toBe(400)
})
+ it('refuses a session that was not opened as a link flow', async () => {
+ // A session started to sign someone IN must not be redirectable into
+ // binding their personnummer to whoever happens to be logged in here.
+ mockAuthenticated()
+ const { admin, client } = mockServiceClient([], {})
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+
+ const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
+ method: 'POST',
+ headers: await flowCookie('login'),
+ body: {},
+ })
+ const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/link')(req))
+
+ expect(status).toBe(400)
+ expect(collectBankIdResult).not.toHaveBeenCalled()
+ expect(client.from).not.toHaveBeenCalled()
+ expect(admin.updateUserById).not.toHaveBeenCalled()
+ })
+
+ it('refuses a link flow that another user opened', async () => {
+ // The shared-browser takeover: A starts "Koppla BankID" and authenticates
+ // but never finishes; B signs in on the same machine and clicks the same
+ // button. Without the owner check, A's personnummer is written against B's
+ // user_id, and A can then sign in as B with their own BankID.
+ mockAuthenticated('user-b')
+ const { admin, client } = mockServiceClient([], {})
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+
+ const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
+ method: 'POST',
+ headers: await flowCookie('link', 'test-session', 'user-a'),
+ })
+ const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/link')(req))
+
+ expect(status).toBe(400)
+ expect(collectBankIdResult).not.toHaveBeenCalled()
+ expect(client.from).not.toHaveBeenCalled()
+ expect(admin.updateUserById).not.toHaveBeenCalled()
+ })
+
+ it('refuses a stale tab after a newer link flow replaced the shared cookie', async () => {
+ mockAuthenticated()
+ const { client } = mockServiceClient([], {})
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+ const headers = await flowCookie('link')
+ headers[BANKID_FLOW_ID_HEADER] = 'older-flow'
+
+ const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
+ method: 'POST',
+ headers,
+ })
+ const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/link')(req))
+
+ expect(status).toBe(400)
+ expect(collectBankIdResult).not.toHaveBeenCalled()
+ expect(client.from).not.toHaveBeenCalled()
+ })
+
+ it('refuses to link twice off one identification', async () => {
+ mockAuthenticated()
+ const { admin, client } = mockServiceClient(
+ [{ data: null }], // pnr not linked to anyone yet
+ {},
+ { error: { code: '23505', message: 'duplicate key' } }, // another tab claimed it first
+ )
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+
+ const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
+ method: 'POST',
+ headers: await flowCookie('link'),
+ })
+ const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/link')(req))
+
+ expect(status).toBe(400)
+ // The identity insert never ran, so nothing was bound twice.
+ const inserts = vi.mocked(client.from).mock.calls.filter((c) => c[0] === 'bankid_identities')
+ expect(inserts).toHaveLength(1) // the pnr lookup only
+ expect(admin.updateUserById).not.toHaveBeenCalled()
+ })
+
+ it('refuses a forged flow cookie', async () => {
+ mockAuthenticated()
+ const { client } = mockServiceClient([], {})
+ vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
+
+ // Same shape, no valid signature: what a same-origin script could plant.
+ const forged = Buffer.from(
+ JSON.stringify({ version: 1, sessionId: 'attacker-session', mode: 'link', startedAt: Date.now() }),
+ ).toString('base64url')
+ const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
+ method: 'POST',
+ headers: { cookie: `${BANKID_FLOW_COOKIE}=${forged}.not-a-signature` },
+ body: {},
+ })
+ const { status } = await parseJsonResponse(await findHandler('POST', '/bankid/link')(req))
+
+ expect(status).toBe(400)
+ expect(client.from).not.toHaveBeenCalled()
+ })
+
it('merges app_metadata so an existing has_password: true survives linking', async () => {
mockAuthenticated()
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
@@ -219,7 +382,7 @@ describe('POST /bankid/link', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
method: 'POST',
- body: { sessionId: 'test-session' },
+ headers: await flowCookie('link'),
})
const { status, body } = await parseJsonResponse<{ data?: { linked?: boolean } }>(
await findHandler('POST', '/bankid/link')(req)
@@ -242,7 +405,7 @@ describe('POST /bankid/link', () => {
const req = createMockRequest('/api/extensions/ext/tic/bankid/link', {
method: 'POST',
- body: { sessionId: 'test-session' },
+ headers: await flowCookie('link'),
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
await findHandler('POST', '/bankid/link')(req)
diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts
index 342cc4c4..558d23ec 100644
--- a/extensions/general/tic/index.ts
+++ b/extensions/general/tic/index.ts
@@ -25,7 +25,15 @@ import {
} from './lib/bankid-client'
import { TICAPIError } from './lib/tic-types'
import type { TICCompanyProfile, TICFinancialReportSummary } from './lib/tic-types'
-import type { BankIdCompleteRequest } from './lib/bankid-types'
+import {
+ BANKID_FLOW_ID_HEADER,
+ FLOW_VERIFIED_WINDOW_SECONDS,
+ FLOW_WINDOW_SECONDS,
+ clearBankIdFlowCookies,
+ isBankIdFlowMode,
+ readBankIdFlow,
+ setBankIdFlowCookies,
+} from './lib/bankid-flow-cookie'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { hashPersonalNumber, encryptPersonalNumberForStorage } from '@/lib/auth/bankid'
import { requireAuth } from '@/lib/auth/require-auth'
@@ -36,6 +44,42 @@ import crypto from 'crypto'
const log = createLogger('tic/bankid')
+/**
+ * Claim a BankID session, atomically and exactly once.
+ *
+ * The flow lives in a cookie now, so every tab of the browser shares one
+ * session and two of them can observe `status: complete` in the same poll tick.
+ * Both would call generateLink(), and the second magic link invalidates the
+ * first, so the tab the user is actually looking at may be the one that fails.
+ * Expiring the cookie on the way out does not prevent it: a Set-Cookie only
+ * applies once a response reaches the browser, and two requests that already
+ * carried the cookie both pass. The primary key is the only genuinely atomic
+ * thing available (serverless has no shared memory), so the loser of the race
+ * gets 23505 and stops before minting anything.
+ *
+ * Returns false when the session was already spent.
+ */
+async function consumeBankIdSession(
+ supabase: SupabaseClient,
+ sessionId: string
+): Promise {
+ const { error } = await supabase
+ .from('bankid_consumed_sessions')
+ .insert({ session_id: sessionId })
+
+ if (!error) return true
+ // 23505 unique_violation: another request got here first.
+ if (error.code === '23505') return false
+
+ // Anything else (table missing, connection lost) must fail closed: minting a
+ // second magic link is worse than making the user authenticate again.
+ log.error('could not claim bankid session; refusing to complete', {
+ code: error.code,
+ message: error.message,
+ })
+ return false
+}
+
/**
* Request SPAR + CompanyRoles enrichment for a completed BankID session and
* cache the CompanyRoles slice in `bankid_enrichment` for the
@@ -787,6 +831,30 @@ export const ticExtension: Extension = {
|| request.headers.get('x-real-ip')
|| '127.0.0.1'
+ // The flow a session is opened for is fixed here and read back at
+ // /complete, so a 'link' session can never be finished as a 'login'.
+ // Validated before the rate limit: a malformed request starts no
+ // billable session, so it should not spend the caller's cooldown and
+ // lock them out of the retry that would have worked.
+ const body = await request.json().catch(() => ({}))
+ const mode = body?.mode
+ if (!isBankIdFlowMode(mode)) {
+ return NextResponse.json({ error: 'mode is required' }, { status: 400 })
+ }
+
+ // A link flow is owned by the user who opened it. /bankid/link binds
+ // the personnummer to whoever is authenticated when it runs, so an
+ // unowned link flow left behind in a shared browser would let the
+ // next person to sign in pick it up and bind the FIRST person's
+ // identity to their own account. Login and signup have no user yet
+ // and stay anonymous.
+ let userId: string | undefined
+ if (mode === 'link') {
+ const auth = await requireAuth()
+ if (auth.error) return auth.error
+ userId = auth.user.id
+ }
+
// Per-IP rate limit (each start = billable TIC session)
const now = Date.now()
const lastStart = bankIdStartCooldowns.get(ip) ?? 0
@@ -804,9 +872,31 @@ export const ticExtension: Extension = {
}
const userAgent = request.headers.get('user-agent') || undefined
-
const session = await startBankIdAuth(ip, userAgent)
- return NextResponse.json({ data: session })
+ const flowId = crypto.randomUUID()
+
+ // sessionId is deliberately NOT in the response. It is a bearer
+ // credential for the holder's personnummer and for a Supabase
+ // session; it goes into the signed HttpOnly cookie instead, and the
+ // client drives the flow without ever seeing it.
+ const response = NextResponse.json({
+ data: {
+ flowId,
+ autoStartToken: session.autoStartToken,
+ qrStartToken: session.qrStartToken,
+ qrStartSecret: session.qrStartSecret,
+ },
+ })
+ await setBankIdFlowCookies(response, {
+ version: 1,
+ sessionId: session.sessionId,
+ flowId,
+ mode,
+ userId,
+ startedAt: Date.now(),
+ expiresAt: Date.now() + FLOW_WINDOW_SECONDS * 1000,
+ })
+ return response
} catch (error) {
if (error instanceof TICAPIError) {
if (error.code === 'NOT_CONFIGURED') {
@@ -835,17 +925,106 @@ export const ticExtension: Extension = {
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 flow = await readBankIdFlow(request)
+ if (!flow) {
+ // No live flow in this browser: the tab is polling something that
+ // has finished, expired, or never belonged to it. Terminal, not an
+ // error, so the client can settle instead of spinning.
+ return NextResponse.json({ error: 'no_session' }, { status: 404 })
+ }
+
+ // The caller says which mode it is showing, and a flow only answers
+ // to its own. Without this a login session started on /login is
+ // picked up by the signup panel (or the reverse) whenever the user
+ // navigates between them. Deliberately NOT clearing: the flow is
+ // still legitimate for the page that started it.
+ const pollBody = await request.json().catch(() => ({}))
+ if (!isBankIdFlowMode(pollBody?.mode) || pollBody.mode !== flow.mode) {
+ return NextResponse.json({ error: 'no_session' }, { status: 404 })
+ }
+ const isProbe = pollBody?.probe === true
+
+ // The cookie is shared by every tab. A newer same-mode /start can
+ // replace it while an older tab is still polling, so mode alone is
+ // not enough: without the flow id, that stale tab would silently
+ // follow and complete the newer person's identification. A mount
+ // probe has no id yet and may discover it, but every active poll must
+ // present the id returned by /start or by that probe.
+ if (!isProbe && request.headers.get(BANKID_FLOW_ID_HEADER) !== flow.flowId) {
+ return NextResponse.json({ error: 'no_session' }, { status: 404 })
+ }
+
+ let result: Awaited>
+ try {
+ result = await pollBankIdSession(flow.sessionId)
+ } catch (error) {
+ // TIC no longer knows this session (404), or answered 410 for an
+ // expired one. Report it as no_session so the client settles
+ // instead of counting it as a service outage.
+ //
+ // Deliberately does NOT clear the cookie. A clearing Set-Cookie is
+ // untargeted: a slow response about a dead session would delete
+ // whatever flow is in the jar by the time it lands, including one
+ // the user has just started in another tab. The stale cookie is
+ // harmless (it answers no_session again and expires on its own),
+ // whereas deleting a live flow costs a billable session.
+ if (error instanceof TICAPIError && (error.statusCode === 404 || error.statusCode === 410)) {
+ return NextResponse.json({ error: 'no_session' }, { status: 404 })
+ }
+ throw error
+ }
+
+ // An expired-session body comes back with no `status` at all
+ // (identityFetch returns a 410 body verbatim). Same treatment.
+ if (!result?.status) {
+ return NextResponse.json({ error: 'no_session' }, { status: 404 })
}
- const result = await pollBankIdSession(sessionId)
if (result.status !== 'pending') {
log.info('poll status', { status: result.status, hintCode: result.hintCode, hasUser: !!result.user?.personalNumber })
}
- return NextResponse.json({ data: result })
+
+ // Whitelist the fields the UI renders. The raw TIC payload carries
+ // user.personalNumber, which the client has never used and must
+ // never receive: this endpoint is skipAuth, so anything it returns
+ // is readable by whoever holds the flow cookie.
+ //
+ // The name (givenName/surname) is withheld from a PROBE. A probe runs
+ // before the person here has confirmed the flow is theirs, so
+ // returning the name would hand a stranger's identity to whoever
+ // opened the page on a shared machine, defeating the confirm card.
+ // The signup e-mail step needs the name, but it is reached only
+ // through the active poll loop (no probe mode), which does get it.
+ const response = NextResponse.json({
+ data: {
+ flowId: isProbe ? flow.flowId : undefined,
+ status: result.status,
+ message: result.message,
+ hintCode: result.hintCode,
+ qrStartToken: result.qrStartToken,
+ qrStartSecret: result.qrStartSecret,
+ user: !isProbe && result.user
+ ? { givenName: result.user.givenName, surname: result.user.surname }
+ : undefined,
+ },
+ })
+
+ // A failed or cancelled order is over. Not cleared here for the same
+ // reason as the dead-session branch above: the Set-Cookie cannot be
+ // aimed at one flow, so a late response would delete a newer one.
+ // The client settles on this status, and the cookie expires.
+ if (result.status === 'complete') {
+ // Identification is done; what remains is the signup e-mail step,
+ // which is a person typing. Re-issue with the longer window so a
+ // user hunting for the right address does not have the session
+ // expire under them: on the old client-state design this step was
+ // bounded only by TIC's own retention.
+ await setBankIdFlowCookies(response, {
+ ...flow,
+ expiresAt: Date.now() + FLOW_VERIFIED_WINDOW_SECONDS * 1000,
+ })
+ }
+ return response
} catch (error) {
if (error instanceof TICAPIError) {
if (error.code === 'RATE_LIMIT_EXCEEDED') {
@@ -870,17 +1049,43 @@ export const ticExtension: Extension = {
skipAuth: true,
handler: async (request: Request) => {
try {
- const body: BankIdCompleteRequest = await request.json()
- const { sessionId, mode, email } = body
-
- if (!sessionId || !mode) {
+ // Session id and mode come from the signed cookie, never the body:
+ // a caller who could name both could complete any session it had
+ // seen, in whatever flow suited it.
+ const flow = await readBankIdFlow(request)
+ if (!flow) {
return NextResponse.json(
- { error: 'sessionId and mode are required' },
+ { error: 'session_invalid', message: 'BankID-sessionen är inte längre giltig. Försök igen.' },
+ { status: 400 }
+ )
+ }
+ const { sessionId, mode } = flow
+
+ // The shared cookie may have been replaced by a newer flow after
+ // this tab started. Only the tab that started or explicitly resumed
+ // the current flow may complete it.
+ if (request.headers.get(BANKID_FLOW_ID_HEADER) !== flow.flowId) {
+ return NextResponse.json(
+ { error: 'session_invalid', message: 'BankID-sessionen är inte längre giltig. Försök igen.' },
{ status: 400 }
)
}
- const trimmedEmail = email?.trim().toLowerCase()
+ if (mode === 'link') {
+ // Linking runs on the authenticated /bankid/link route, which
+ // proves who is being linked. Completing a link flow here would
+ // create or sign in an account off a session opened for something
+ // else entirely.
+ return NextResponse.json(
+ { error: 'session_invalid', message: 'BankID-sessionen är inte längre giltig. Försök igen.' },
+ { status: 400 }
+ )
+ }
+
+ const body = await request.json().catch(() => ({}))
+ const trimmedEmail = typeof body?.email === 'string'
+ ? body.email.trim().toLowerCase()
+ : undefined
if (mode === 'signup' && !trimmedEmail) {
return NextResponse.json(
@@ -889,14 +1094,26 @@ export const ticExtension: Extension = {
)
}
+ /**
+ * Every exit from here clears the flow, so a session is usable
+ * exactly once. Two tabs that both observe completion cannot both
+ * mint a magic link: the second finds no cookie and gets
+ * session_invalid, instead of a generateLink that silently
+ * invalidates the first tab's link and breaks the sign-in.
+ */
+ const settle = (response: NextResponse): NextResponse => {
+ clearBankIdFlowCookies(response)
+ return response
+ }
+
// Verify BankID session is complete. The message surfaces directly
// in the register-page toast, so it must be Swedish.
const session = await collectBankIdResult(sessionId)
if (session.status !== 'complete' || !session.user) {
- return NextResponse.json(
+ return settle(NextResponse.json(
{ error: 'session_invalid', message: 'BankID-sessionen är inte längre giltig. Försök igen.' },
{ status: 400 }
- )
+ ))
}
const { personalNumber, givenName, surname, name } = session.user
@@ -912,20 +1129,35 @@ export const ticExtension: Extension = {
if (mode === 'login') {
if (!existing) {
- return NextResponse.json({
+ // Terminal for a login flow: the user is sent to signup, which
+ // starts its own session.
+ return settle(NextResponse.json({
error: 'no_account',
givenName,
surname,
- }, { status: 404 })
+ }, { 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(
+ // Data problem, not a transient one: an identity with no user
+ // email will never complete. settle() so it is not re-offered as
+ // a resumable flow on the next page load.
+ return settle(NextResponse.json(
{ error: 'session_invalid', message: 'User account not found' },
{ status: 500 }
- )
+ ))
+ }
+
+ // Claim the session BEFORE minting anything. Two tabs sharing this
+ // browser's flow cookie can both arrive here; only one may mint,
+ // because the second magic link invalidates the first.
+ if (!await consumeBankIdSession(supabase, sessionId)) {
+ return settle(NextResponse.json(
+ { error: 'session_invalid', message: 'BankID-sessionen är inte längre giltig. Försök igen.' },
+ { status: 400 }
+ ))
}
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
@@ -935,30 +1167,38 @@ export const ticExtension: Extension = {
if (linkError || !link?.properties?.hashed_token) {
log.error('generateLink failed for login', { message: linkError?.message, code: linkError?.code })
- return NextResponse.json(
+ // The session is already consumed, so a retry would fail with
+ // session_invalid anyway; settle() clears the cookie now instead
+ // of leaving a spent flow to be re-offered as resumable.
+ return settle(NextResponse.json(
{ error: 'Failed to create session' },
{ status: 500 }
- )
+ ))
}
// Refresh enrichment so /select-company sees current Bolagsverket roles.
await fetchAndStoreEnrichment(sessionId, existing.user_id, supabase)
- return NextResponse.json({
+ // settle(): the magic link is minted, so the flow is spent. A
+ // second tab reaching here would mint another and invalidate this
+ // one; it now gets session_invalid instead.
+ return settle(NextResponse.json({
data: {
tokenHash: link.properties.hashed_token,
type: 'magiclink',
isNewUser: false,
},
- })
+ }))
}
// mode === 'signup'
if (existing) {
- return NextResponse.json(
+ // Terminal: this BankID already has an account, so the answer is
+ // to sign in, not to retry this session.
+ return settle(NextResponse.json(
{ error: 'already_linked', message: 'This BankID is already linked to an account' },
{ status: 409 }
- )
+ ))
}
// Create new Supabase user. Email uniqueness is checked by createUser
@@ -981,10 +1221,15 @@ export const ticExtension: Extension = {
// /bankid/link route so email ownership is proven by password login
// first. (CWE-287)
if (createError?.code === 'email_exists') {
+ // The session id is a bearer credential for a personnummer at
+ // TIC; a prefix is enough to correlate log lines.
log.warn('bankid signup rejected: email already registered', {
- sessionId,
+ sessionIdPrefix: sessionId.slice(0, 8),
pnrHashPrefix: pnrHash.slice(0, 8),
})
+ // Deliberately NOT settled: nothing was consumed and the user
+ // may simply have typed the wrong address. Leaving the flow
+ // alive lets them correct it without a second BankID round trip.
return NextResponse.json(
{
error: 'account_exists',
@@ -1009,14 +1254,14 @@ export const ticExtension: Extension = {
// All-or-nothing signup: if any step after createUser fails, delete
// the just-created user so the same email/BankID can retry cleanly.
- // Leaving the half-created account behind strands the user — a retry
+ // Leaving the half-created account behind strands the user: a retry
// hits account_exists/already_linked, but the account only has a
// random password they never saw, so "log in instead" requires a
// password reset. bankid_identities cascades on user delete.
const rollbackSignup = async (step: string) => {
const { error: deleteError } = await supabase.auth.admin.deleteUser(userId)
if (deleteError) {
- log.error(`signup rollback after failed ${step} could not delete user — orphaned account`, {
+ log.error(`signup rollback after failed ${step} could not delete user: orphaned account`, {
userId,
message: deleteError.message,
})
@@ -1060,6 +1305,17 @@ export const ticExtension: Extension = {
)
}
+ // Claim the session before minting. Placed after createUser so the
+ // recoverable account_exists path above leaves the flow reusable,
+ // and before generateLink so two tabs cannot both mint.
+ if (!await consumeBankIdSession(supabase, sessionId)) {
+ await rollbackSignup('session already consumed')
+ return settle(NextResponse.json(
+ { error: 'session_invalid', message: 'BankID-sessionen är inte längre giltig. Försök igen.' },
+ { status: 400 }
+ ))
+ }
+
// Generate magic link for session
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
type: 'magiclink',
@@ -1069,22 +1325,26 @@ export const ticExtension: Extension = {
if (linkError || !link?.properties?.hashed_token) {
log.error('generateLink failed for signup', { message: linkError?.message, code: linkError?.code })
await rollbackSignup('generateLink')
- return NextResponse.json(
+ // The session was already consumed above, so this flow cannot be
+ // retried; settle() clears it rather than leaving a spent,
+ // rolled-back flow to be re-offered as resumable.
+ return settle(NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
- )
+ ))
}
// Enrichment (CompanyRoles): pre-fills /select-company picker.
await fetchAndStoreEnrichment(sessionId, userId, supabase)
- return NextResponse.json({
+ // settle(): account created and magic link minted. The flow is spent.
+ return settle(NextResponse.json({
data: {
tokenHash: link.properties.hashed_token,
type: 'magiclink',
isNewUser: true,
},
- })
+ }))
} catch (error) {
if (error instanceof TICAPIError) {
log.error('complete failed: TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
@@ -1109,23 +1369,34 @@ export const ticExtension: Extension = {
},
{
- method: 'DELETE',
- path: '/bankid/:sessionId',
+ method: 'POST',
+ // Was DELETE /bankid/:sessionId. The id is no longer something the
+ // client knows, so cancelling is now "end whatever flow this browser
+ // holds": it cannot be aimed at anyone else's session.
+ path: '/bankid/cancel',
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 })
- }
+ // A malformed cookie must still be clearable rather than turning
+ // Avbryt into a 500.
+ const flow = await readBankIdFlow(request).catch(() => null)
- await cancelBankIdSession(sessionId)
- return NextResponse.json({ data: { cancelled: true } })
- } catch (error) {
- log.error('cancel failed', error)
- return NextResponse.json({ error: 'Failed to cancel session' }, { status: 500 })
+ if (flow && request.headers.get(BANKID_FLOW_ID_HEADER) !== flow.flowId) {
+ // A newer tab replaced the shared cookie. This caller may settle its
+ // own stale UI, but it must not cancel or clear the newer flow.
+ return NextResponse.json({ data: { cancelled: false, replaced: true } })
}
+
+ const response = NextResponse.json({ data: { cancelled: true } })
+ clearBankIdFlowCookies(response)
+
+ if (flow) {
+ try {
+ await cancelBankIdSession(flow.sessionId)
+ } catch (error) {
+ log.error('cancel failed', error)
+ }
+ }
+ return response
},
},
@@ -1146,20 +1417,52 @@ export const ticExtension: Extension = {
if (auth.error) return auth.error
const userId = auth.user.id
- const body = await request.json()
- const { sessionId } = body
+ // Cookie, not body, and the mode must be the one the session was
+ // opened for. Otherwise a session started to sign SOMEONE ELSE in
+ // could be redirected into binding their personnummer to whoever is
+ // currently logged in on this browser.
+ const flow = await readBankIdFlow(request)
+ if (!flow || flow.mode !== 'link') {
+ return NextResponse.json(
+ { error: 'session_invalid', message: 'BankID session is not complete' },
+ { status: 400 }
+ )
+ }
- if (!sessionId) {
- return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
+ if (request.headers.get(BANKID_FLOW_ID_HEADER) !== flow.flowId) {
+ return NextResponse.json(
+ { error: 'session_invalid', message: 'BankID session is not complete' },
+ { status: 400 }
+ )
+ }
+
+ // The flow must belong to the caller. Mode alone is not enough: this
+ // route binds a personnummer to whoever is authenticated right now,
+ // so a link flow that someone else started and abandoned in this
+ // browser would otherwise bind THEIR identity to THIS account, and
+ // they could then sign in as this user with their own BankID.
+ if (flow.userId !== userId) {
+ log.warn('bankid link rejected: flow belongs to another user')
+ return NextResponse.json(
+ { error: 'session_invalid', message: 'BankID session is not complete' },
+ { status: 400 }
+ )
+ }
+ const { sessionId } = flow
+
+ /** Linking is single-use for the same reason completing is. */
+ const settle = (response: NextResponse): NextResponse => {
+ clearBankIdFlowCookies(response)
+ return response
}
// Verify BankID session
const session = await collectBankIdResult(sessionId)
if (session.status !== 'complete' || !session.user) {
- return NextResponse.json(
+ return settle(NextResponse.json(
{ error: 'session_invalid', message: 'BankID session is not complete' },
{ status: 400 }
- )
+ ))
}
const { personalNumber, givenName, surname } = session.user
@@ -1174,14 +1477,23 @@ export const ticExtension: Extension = {
.single()
if (existing && existing.user_id !== userId) {
- return NextResponse.json(
+ return settle(NextResponse.json(
{ error: 'already_linked', message: 'This BankID is already linked to another account' },
{ status: 409 }
- )
+ ))
}
if (existing && existing.user_id === userId) {
- return NextResponse.json({ data: { linked: true, alreadyLinked: true } })
+ return settle(NextResponse.json({ data: { linked: true, alreadyLinked: true } }))
+ }
+
+ // Single-use, same reason as /complete: two tabs sharing this
+ // browser's flow must not both act on one identification.
+ if (!await consumeBankIdSession(supabase, sessionId)) {
+ return settle(NextResponse.json(
+ { error: 'session_invalid', message: 'BankID session is not complete' },
+ { status: 400 }
+ ))
}
// Link BankID to current user
@@ -1197,10 +1509,11 @@ export const ticExtension: Extension = {
if (insertError) {
log.error('link insert failed', { message: insertError.message, code: insertError.code })
- return NextResponse.json(
+ // Session already consumed above, so the flow is spent; clear it.
+ return settle(NextResponse.json(
{ error: 'Failed to link BankID' },
{ status: 500 }
- )
+ ))
}
// Read-merge-write: updateUserById REPLACES app_metadata wholesale
@@ -1214,7 +1527,7 @@ export const ticExtension: Extension = {
app_metadata: { ...priorMeta, bankid_linked: true },
})
- return NextResponse.json({ data: { linked: true } })
+ return settle(NextResponse.json({ data: { linked: true } }))
} catch (error) {
if (error instanceof TICAPIError) {
log.error('link failed: TIC API error', { statusCode: error.statusCode, code: error.code, message: error.message })
@@ -1261,7 +1574,7 @@ export const ticExtension: Extension = {
// Clear app_metadata.bankid_linked so MFA enforcement resumes.
// Read-merge-write: updateUserById REPLACES app_metadata wholesale
// (same rationale as /bankid/link above). Writing only
- // { bankid_linked: false } would wipe has_password — a BankID-only
+ // { bankid_linked: false } would wipe has_password: a BankID-only
// user (has_password: false) would then be inferred as HAVING a
// password (lib/auth/has-password.ts) and could strand themselves
// with no working login method.
diff --git a/extensions/general/tic/lib/bankid-flow-cookie.ts b/extensions/general/tic/lib/bankid-flow-cookie.ts
new file mode 100644
index 00000000..42747fa8
--- /dev/null
+++ b/extensions/general/tic/lib/bankid-flow-cookie.ts
@@ -0,0 +1,364 @@
+/**
+ * Server-held state for an in-flight BankID flow.
+ *
+ * Why the session id is not allowed near the browser's JavaScript
+ * -------------------------------------------------------------
+ * A TIC `sessionId` is an unauthenticated bearer credential. Anyone holding
+ * one can POST it to /bankid/poll (skipAuth) and read the holder's
+ * personnummer, or POST it to /bankid/complete with mode 'login' and receive a
+ * `tokenHash` that `verifyOtp` turns into a full Supabase session, with MFA
+ * skipped because BankID accounts carry `bankid_linked`. It used to be handed
+ * to the client and kept in `sessionStorage`, which made every one of those a
+ * single XSS away, and made the obvious fix for the cross-tab bug (move it to
+ * `localStorage` so the tab BankID returns to can resume) a straight upgrade
+ * of any same-origin XSS into a login-fixation primitive.
+ *
+ * So the id never leaves the server. It lives in an HttpOnly cookie that the
+ * browser attaches to the BankID endpoints and nothing else:
+ *
+ * • HttpOnly script cannot read it, so XSS cannot steal a session.
+ * • signed script cannot FORGE one. It does not stop a script from
+ * planting a cookie the server genuinely minted (an attacker
+ * can fetch one with curl), and it does not bind the flow to
+ * a browser or a person. Planting a completed identification
+ * is what login fixation is, so the protection against that
+ * is not here: it is the client refusing to consume a flow
+ * this browsing context did not start without an explicit
+ * confirmation. See BankIdAuth's resume handling.
+ * • __Host- forbids Domain and forces Path=/, which leaves exactly one
+ * possible (name, domain, path) for this cookie. Without it a
+ * script can plant the same name at a LONGER path, which the
+ * browser sends first and the server's deletion cannot reach.
+ * • SameSite=Lax the BankID app returns the user by top-level navigation
+ * from outside the site; Strict would drop the cookie there
+ * and strand exactly the flow this exists to serve.
+ * • short Max-Age a BankID order is good for ~3 minutes. An abandoned flow
+ * should not outlive it by much.
+ *
+ * Being a cookie rather than client storage is also what fixes the original
+ * bug: cookies are shared by every tab of the origin, so whichever tab BankID
+ * returns the user to, new or reloaded, simply polls and continues. No
+ * client-side handoff, no cross-tab lock, no heartbeat.
+ *
+ * `mode` is pinned here at /start and read back at /complete, so the flow a
+ * session was opened for is the only flow that can finish it. The client
+ * cannot ask to complete a 'link' session as a 'login'.
+ */
+
+import { NextResponse } from 'next/server'
+
+export type BankIdFlowMode = 'login' | 'signup' | 'link'
+
+/**
+ * HttpOnly, signed. Holds the session id, and is never readable by scripts.
+ *
+ * The `__Host-` prefix is load-bearing, not decoration. Browsers only refuse a
+ * `document.cookie` write when it collides with an existing HttpOnly cookie on
+ * the exact (name, domain, path) triple, so without the prefix a script could
+ * set the SAME NAME at a longer path; RFC 6265 §5.4 serialises longer paths
+ * first, so the planted one would win, and a Max-Age=0 written to the shorter
+ * path could never delete it. `__Host-` forbids `Domain` and forces `Path=/`,
+ * which leaves exactly one possible (name, domain, path) for this cookie: the
+ * one the server owns. That also blocks cookie-tossing from a sibling
+ * white-label subdomain.
+ *
+ * Losing the narrow Path is the price. It is worth it: the narrow Path only
+ * ever bought log hygiene, whereas the shadowing it permitted was a way to
+ * plant a completed session in someone else's browser.
+ */
+export const BANKID_FLOW_COOKIE = '__Host-accounted-bankid-flow'
+
+/**
+ * Non-secret identifier that binds one browser tab to the flow it started or
+ * explicitly resumed. The signed cookie remains the credential; this header
+ * only prevents a stale tab from silently acting on a newer flow that replaced
+ * the shared cookie.
+ */
+export const BANKID_FLOW_ID_HEADER = 'x-bankid-flow-id'
+
+/**
+ * How long a fresh order may be resumed. A BankID order is good for ~3 minutes;
+ * the rest covers the app switch and a slow return.
+ */
+export const FLOW_WINDOW_SECONDS = 300
+
+/**
+ * Replaces the window above once the identification has actually happened. The
+ * signup flow then asks for an e-mail, and a user hunting for the right address
+ * (or switching to a password manager and back) must not have the session
+ * expire under them: on `main` this step was bounded only by TIC's own
+ * retention, so a short shared budget would have been a real regression.
+ */
+export const FLOW_VERIFIED_WINDOW_SECONDS = 900
+
+/** Sanity bound on a decoded expiry, so no cookie can claim an unbounded life. */
+const MAX_WINDOW_MS = FLOW_VERIFIED_WINDOW_SECONDS * 1000
+
+/**
+ * Ceiling on a whole flow, measured from /start. Bounds the re-issue chain:
+ * an identification cannot be kept resumable indefinitely by polling.
+ */
+export const MAX_TOTAL_LIFE_SECONDS = FLOW_WINDOW_SECONDS + FLOW_VERIFIED_WINDOW_SECONDS
+const MAX_TOTAL_LIFE_MS = MAX_TOTAL_LIFE_SECONDS * 1000
+
+const SIGNING_CONTEXT = 'accounted-bankid-flow-v1:'
+
+export interface BankIdFlowState {
+ version: 1
+ sessionId: string
+ /** Random, non-secret tab binding. The BankID session id remains HttpOnly. */
+ flowId: string
+ mode: BankIdFlowMode
+ /**
+ * Who opened a `link` flow. Linking binds a personnummer to whoever is
+ * authenticated when /bankid/link runs, so without this a flow started by one
+ * person and left unfinished could be picked up by the next person to use the
+ * browser and bind the FIRST person's identity to the SECOND person's
+ * account. Absent for login and signup, which have no user yet.
+ */
+ userId?: string
+ /**
+ * When /start opened this flow. Carried so the total life can be capped:
+ * /poll extends `expiresAt` when it observes completion, and without a fixed
+ * origin a caller could keep polling to push the window forward forever,
+ * leaving a usable identification in the jar for as long as TIC retains it.
+ */
+ startedAt: number
+ /** Absolute expiry, enforced server-side; the browser's Max-Age only mirrors it. */
+ expiresAt: number
+}
+
+type Environment = Record
+
+export function isBankIdFlowMode(value: unknown): value is BankIdFlowMode {
+ return value === 'login' || value === 'signup' || value === 'link'
+}
+
+/**
+ * Unlike the session-timeout cookie, which degrades to unsigned-and-ignored
+ * when misconfigured, this one throws: a BankID flow that cannot be bound to
+ * the browser that started it must not run at all.
+ */
+function getSigningSecret(env: Environment): string {
+ const dedicated = env.BANKID_ENCRYPTION_KEY?.trim()
+ if (dedicated) return dedicated
+
+ const sessionSecret = env.SESSION_TIMEOUT_SECRET?.trim()
+ if (sessionSecret) return sessionSecret
+
+ const serviceRole = env.SUPABASE_SERVICE_ROLE_KEY?.trim()
+ if (serviceRole) return serviceRole
+
+ throw new Error(
+ 'BankID flow cookie requires BANKID_ENCRYPTION_KEY, SESSION_TIMEOUT_SECRET or SUPABASE_SERVICE_ROLE_KEY',
+ )
+}
+
+function bytesToBase64Url(bytes: Uint8Array): string {
+ let binary = ''
+ for (const byte of bytes) binary += String.fromCharCode(byte)
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '')
+}
+
+// The explicit ArrayBuffer parameter matters: a bare Uint8Array is
+// Uint8Array, which crypto.subtle rejects as a BufferSource
+// because it could be backed by a SharedArrayBuffer. Same annotation as
+// lib/auth/session-timeout.ts, for the same reason.
+function base64UrlToBytes(value: string): Uint8Array | null {
+ try {
+ const base64 = value.replaceAll('-', '+').replaceAll('_', '/')
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
+ const binary = atob(padded)
+ const bytes = new Uint8Array(binary.length)
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index)
+ }
+ return bytes
+ } catch {
+ return null
+ }
+}
+
+async function importSigningKey(secret: string): Promise {
+ // HKDF-derived with a purpose-bound info string, never the raw secret: the
+ // same key material also encrypts personnummer at rest (lib/auth/bankid.ts)
+ // and the SUPABASE_SERVICE_ROLE_KEY fallback is a privileged credential.
+ // Neither may double as an HMAC key directly.
+ const baseKey = await crypto.subtle.importKey(
+ 'raw',
+ new TextEncoder().encode(secret),
+ 'HKDF',
+ false,
+ ['deriveKey'],
+ )
+ return crypto.subtle.deriveKey(
+ {
+ name: 'HKDF',
+ hash: 'SHA-256',
+ salt: new Uint8Array(32),
+ info: new TextEncoder().encode(SIGNING_CONTEXT),
+ },
+ baseKey,
+ { name: 'HMAC', hash: 'SHA-256', length: 256 },
+ false,
+ ['sign', 'verify'],
+ )
+}
+
+export async function signBankIdFlow(
+ state: BankIdFlowState,
+ env: Environment = process.env,
+): Promise {
+ const payload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(state)))
+ const key = await importSigningKey(getSigningSecret(env))
+ const signature = await crypto.subtle.sign(
+ 'HMAC',
+ key,
+ new TextEncoder().encode(`${SIGNING_CONTEXT}${payload}`),
+ )
+ return `${payload}.${bytesToBase64Url(new Uint8Array(signature))}`
+}
+
+/**
+ * Verify and decode a flow cookie. Returns null for anything that is not a
+ * cookie this server minted and still considers live: bad signature, wrong
+ * shape, unknown mode, an unowned `link` flow, a `startedAt` in the future,
+ * one whose `expiresAt` is past or claims more than MAX_WINDOW, or a whole
+ * flow older than MAX_TOTAL_LIFE. Expiry is re-checked here rather than
+ * trusted to the browser's Max-Age, which a client controls.
+ */
+export async function verifyBankIdFlow(
+ value: string | undefined,
+ env: Environment = process.env,
+ now: number = Date.now(),
+): Promise {
+ if (!value) return null
+ const [payload, signature, extra] = value.split('.')
+ if (!payload || !signature || extra !== undefined) return null
+
+ const signatureBytes = base64UrlToBytes(signature)
+ if (!signatureBytes) return null
+
+ try {
+ const key = await importSigningKey(getSigningSecret(env))
+ const valid = await crypto.subtle.verify(
+ 'HMAC',
+ key,
+ signatureBytes,
+ new TextEncoder().encode(`${SIGNING_CONTEXT}${payload}`),
+ )
+ if (!valid) return null
+ } catch {
+ return null
+ }
+
+ const decoded = base64UrlToBytes(payload)
+ if (!decoded) return null
+
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(new TextDecoder().decode(decoded))
+ } catch {
+ return null
+ }
+
+ if (!parsed || typeof parsed !== 'object') return null
+ const state = parsed as Partial
+ if (state.version !== 1) return null
+ if (typeof state.sessionId !== 'string' || !state.sessionId) return null
+ if (typeof state.flowId !== 'string' || !state.flowId) return null
+ if (!isBankIdFlowMode(state.mode)) return null
+ if (state.userId !== undefined && (typeof state.userId !== 'string' || !state.userId)) return null
+ // A `link` flow with no owner cannot be validated against the caller, and an
+ // unowned link is exactly the shape that binds the wrong identity.
+ if (state.mode === 'link' && !state.userId) return null
+ if (typeof state.startedAt !== 'number' || !Number.isFinite(state.startedAt)) return null
+ if (state.startedAt > now) return null
+ if (typeof state.expiresAt !== 'number' || !Number.isFinite(state.expiresAt)) return null
+ if (now > state.expiresAt) return null
+ // Expiry is enforced here rather than trusted to the browser's Max-Age, which
+ // a client controls. The upper bound stops any cookie claiming a longer life
+ // than the longest window this server ever issues.
+ if (state.expiresAt - now > MAX_WINDOW_MS) return null
+ // Hard cap on the whole flow, not just this cookie. /poll re-issues with a
+ // longer window when it sees a completed identification; without a cap
+ // measured from the original start, polling in a loop would keep a usable
+ // identification alive for as long as TIC retains the session.
+ if (now - state.startedAt > MAX_TOTAL_LIFE_MS) return null
+
+ return state as BankIdFlowState
+}
+
+/**
+ * `__Host-` requires Secure, Path=/ and no Domain; a cookie missing any of them
+ * is rejected outright by the browser. Secure is therefore unconditional here,
+ * and that costs nothing: isBankIdEnabled() (lib/auth/bankid.ts) returns false
+ * whenever NEXT_PUBLIC_SELF_HOSTED is set, so there is no plain-http BankID
+ * deployment to accommodate. An earlier version made Secure conditional on
+ * x-forwarded-proto for that imagined case, which only meant a proxy that omits
+ * the header would silently ship this cookie unprotected on a real HTTPS site.
+ */
+const FLOW_COOKIE_OPTIONS = {
+ httpOnly: true,
+ secure: true,
+ sameSite: 'lax',
+ path: '/',
+} as const
+
+/** Attach a flow to the response, replacing any flow already there. */
+export async function setBankIdFlowCookies(
+ response: NextResponse,
+ state: BankIdFlowState,
+ env: Environment = process.env,
+ now: number = Date.now(),
+): Promise {
+ response.cookies.set(BANKID_FLOW_COOKIE, await signBankIdFlow(state, env), {
+ ...FLOW_COOKIE_OPTIONS,
+ // Mirrors the signed expiry so an abandoned flow also disappears from the
+ // jar; the server-side check in verifyBankIdFlow is the real bound.
+ maxAge: Math.max(1, Math.ceil((state.expiresAt - now) / 1000)),
+ })
+}
+
+/** End the flow. Called on every terminal outcome. */
+export function clearBankIdFlowCookies(response: NextResponse): void {
+ // Same name, same path, same attributes: a deletion written to a different
+ // path would leave the real cookie in place.
+ response.cookies.set(BANKID_FLOW_COOKIE, '', { ...FLOW_COOKIE_OPTIONS, maxAge: 0 })
+}
+
+/**
+ * Read and verify the flow attached to an incoming request.
+ *
+ * Fails closed when the header carries more than one cookie of this name.
+ * `__Host-` should make that impossible, but the cost of being wrong about a
+ * browser's prefix handling is that a planted duplicate gets used instead of
+ * the real one, so ambiguity is treated as no flow rather than resolved by
+ * picking a winner.
+ */
+export async function readBankIdFlow(
+ request: Request,
+ env: Environment = process.env,
+): Promise {
+ const header = request.headers.get('cookie')
+ if (!header) return null
+
+ const values: string[] = []
+ for (const part of header.split(';')) {
+ const separator = part.indexOf('=')
+ if (separator === -1) continue
+ if (part.slice(0, separator).trim() !== BANKID_FLOW_COOKIE) continue
+ const raw = part.slice(separator + 1).trim()
+ // A malformed percent-escape throws URIError. Treat it as an unusable
+ // cookie rather than letting it become a 500 in every handler that reads
+ // the flow.
+ try {
+ values.push(decodeURIComponent(raw))
+ } catch {
+ return null
+ }
+ }
+
+ if (values.length !== 1) return null
+ return verifyBankIdFlow(values[0], env)
+}
diff --git a/messages/en.json b/messages/en.json
index f43fe463..b9217f57 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -314,6 +314,14 @@
"bankid_no_account_create": "Or create a new account",
"bankid_unavailable_title": "No password?",
"bankid_unavailable_body": "If you created your account with BankID you can use \"Forgot password?\" to get a sign-in link by email.",
+ "bankid_cancelling": "Cancelling...",
+ "bankid_completing": "Completing the BankID identification...",
+ "bankid_finish_in_app": "Complete the identification in the BankID app",
+ "bankid_resume_title": "BankID identification in progress",
+ "bankid_resume_description": "This browser has an ongoing BankID identification. Continue only if you started it.",
+ "bankid_resume_continue": "Continue",
+ "bankid_resume_hint": "Complete the BankID identification...",
+ "bankid_resume_restart": "Start over",
"terms_prefix": "By signing in you agree to our",
"terms_link": "terms",
"terms_and": "and",
@@ -1162,6 +1170,7 @@
"terms_and": "and",
"privacy_link": "privacy policy",
"bankid_failed_title": "BankID failed",
+ "bankid_cancel_failed": "Could not cancel the BankID identification. Please try again.",
"bankid_failed_description": "Could not verify your identity.",
"bankid_already_linked_title": "BankID already linked",
"bankid_already_linked_description": "This BankID is already linked to an account. Try signing in instead.",
diff --git a/messages/sv.json b/messages/sv.json
index 6bd74a94..50735c69 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -314,6 +314,14 @@
"bankid_no_account_create": "Eller skapa ett nytt konto",
"bankid_unavailable_title": "Har du inget lösenord?",
"bankid_unavailable_body": "Om du skapade ditt konto med BankID kan du använda \"Glömt lösenord?\" för att få en inloggningslänk via e-post.",
+ "bankid_cancelling": "Avbryter...",
+ "bankid_completing": "Slutför BankID-identifieringen...",
+ "bankid_finish_in_app": "Slutför identifieringen i BankID-appen",
+ "bankid_resume_title": "BankID-identifiering pågår",
+ "bankid_resume_description": "Den här webbläsaren har en påbörjad BankID-identifiering. Fortsätt bara om det är du som startade den.",
+ "bankid_resume_continue": "Fortsätt",
+ "bankid_resume_hint": "Slutför BankID-identifieringen...",
+ "bankid_resume_restart": "Starta om",
"terms_prefix": "Genom att logga in godkänner du våra",
"terms_link": "villkor",
"terms_and": "och",
@@ -1162,6 +1170,7 @@
"terms_and": "och",
"privacy_link": "integritetspolicy",
"bankid_failed_title": "BankID misslyckades",
+ "bankid_cancel_failed": "Kunde inte avbryta BankID-identifieringen. Försök igen.",
"bankid_failed_description": "Kunde inte verifiera din identitet.",
"bankid_already_linked_title": "BankID redan kopplat",
"bankid_already_linked_description": "Detta BankID är redan kopplat till ett konto. Försök logga in istället.",
diff --git a/supabase/migrations/20260815120000_bankid_consumed_sessions.sql b/supabase/migrations/20260815120000_bankid_consumed_sessions.sql
new file mode 100644
index 00000000..86d859ab
--- /dev/null
+++ b/supabase/migrations/20260815120000_bankid_consumed_sessions.sql
@@ -0,0 +1,53 @@
+-- Makes a BankID session usable exactly once, atomically.
+--
+-- The BankID flow now lives in a cookie rather than per-tab client storage, so
+-- every tab of the browser shares one session. That is what lets the tab the
+-- BankID app returns the user to finish a flow another tab started, and it is
+-- also why two tabs can observe `status: complete` inside the same poll tick
+-- and both try to finish it.
+--
+-- Both would call supabase.auth.admin.generateLink(), and the second magic link
+-- invalidates the first, so whichever tab the user is actually looking at may
+-- be the one that fails with "Inloggningen med BankID misslyckades".
+--
+-- Clearing the flow cookie on the way out does NOT prevent this: a Set-Cookie
+-- only takes effect once a response reaches the browser, so two requests that
+-- both already carried the cookie both pass the check. There is no shared
+-- process memory to coordinate in on serverless either. A unique index is the
+-- only thing here that is genuinely atomic: the second INSERT raises 23505 and
+-- that request stops before minting anything.
+--
+-- Rows are bookkeeping, not business data: a BankID session id is meaningless
+-- to TIC within the hour, and nothing reads a row back. They are nonetheless
+-- retained rather than deleted, so a replayed id is still recognised as spent
+-- instead of silently becoming reusable.
+--
+-- There is deliberately NO cleanup job in this migration. At current volume
+-- (single-digit BankID authentications per day) the table takes decades to
+-- reach a megabyte, and a pruning cron is a moving part that can fail open.
+-- consumed_at is indexed so an age-based prune can be added later without a
+-- second migration. What the retention does mean, and is worth being explicit
+-- about: this is a permanent record of when each BankID identification
+-- happened, which is why the table is service-role only.
+
+CREATE TABLE public.bankid_consumed_sessions (
+ session_id TEXT PRIMARY KEY,
+ consumed_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+-- For a future age-based prune; nothing queries it today.
+CREATE INDEX idx_bankid_consumed_sessions_consumed_at
+ ON public.bankid_consumed_sessions (consumed_at);
+
+ALTER TABLE public.bankid_consumed_sessions ENABLE ROW LEVEL SECURITY;
+
+-- No policies at all, deliberately. The table is written and read only by the
+-- TIC extension's BankID handlers through createServiceClient(), which bypasses
+-- RLS. Nothing user-facing may see which sessions exist: the ids are bearer
+-- credentials for a personnummer and for a Supabase session, and the row set is
+-- a record of who authenticated and when.
+
+COMMENT ON TABLE public.bankid_consumed_sessions IS
+ 'Spent BankID session ids. The PK is the single-use guard for /bankid/complete and /bankid/link; see migration header.';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 3373f168..20cfdbd8 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -683,6 +683,8 @@ export function createMockRequest(
method?: string
body?: unknown
searchParams?: Record
+ /** Extra request headers, e.g. `cookie` for routes that read one. */
+ headers?: Record
}
): Request {
const fullUrl = new URL(url, 'http://localhost:3000')
@@ -693,7 +695,7 @@ export function createMockRequest(
}
return new Request(fullUrl.toString(), {
method: options?.method || 'GET',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', ...options?.headers },
...(options?.body ? { body: JSON.stringify(options.body) } : {}),
})
}
diff --git a/tests/pg/bankid-consumed-sessions.pg.test.ts b/tests/pg/bankid-consumed-sessions.pg.test.ts
new file mode 100644
index 00000000..8249e258
--- /dev/null
+++ b/tests/pg/bankid-consumed-sessions.pg.test.ts
@@ -0,0 +1,99 @@
+import { randomUUID } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { getPool, withUserContext } from './setup'
+import { insertAuthUser } from './fixtures'
+
+/**
+ * bankid_consumed_sessions (migration 20260815120000) is the single-use guard
+ * for a BankID identification.
+ *
+ * The flow now lives in a cookie, so every tab of a browser shares one session
+ * and two of them can observe `status: complete` in the same poll tick. Both
+ * would call generateLink(), and the second magic link invalidates the first,
+ * so the tab the user is looking at may be the one that fails. Expiring the
+ * cookie on the way out does not prevent that: a Set-Cookie only applies once a
+ * response reaches the browser, and both requests already carried it.
+ *
+ * These pin the two properties the application actually relies on: the claim is
+ * atomic, and the table is invisible to end users. A session id is a bearer
+ * credential for a personnummer and for a Supabase session, and the row set is
+ * a record of who authenticated and when.
+ */
+describe('bankid_consumed_sessions (pg)', () => {
+ it('lets exactly one claim win for a given session id', async () => {
+ const sessionId = `sess-${randomUUID()}`
+
+ await getPool().query(
+ 'INSERT INTO public.bankid_consumed_sessions (session_id) VALUES ($1)',
+ [sessionId],
+ )
+
+ await expect(
+ getPool().query(
+ 'INSERT INTO public.bankid_consumed_sessions (session_id) VALUES ($1)',
+ [sessionId],
+ ),
+ ).rejects.toMatchObject({ code: '23505' })
+ })
+
+ it('does not collide across different sessions', async () => {
+ const a = `sess-${randomUUID()}`
+ const b = `sess-${randomUUID()}`
+ await getPool().query(
+ 'INSERT INTO public.bankid_consumed_sessions (session_id) VALUES ($1), ($2)',
+ [a, b],
+ )
+
+ const { rows } = await getPool().query<{ n: number }>(
+ 'SELECT count(*)::int AS n FROM public.bankid_consumed_sessions WHERE session_id IN ($1, $2)',
+ [a, b],
+ )
+ expect(rows[0]!.n).toBe(2)
+ })
+
+ it('stamps consumed_at so spent sessions can be aged out', async () => {
+ const sessionId = `sess-${randomUUID()}`
+ await getPool().query(
+ 'INSERT INTO public.bankid_consumed_sessions (session_id) VALUES ($1)',
+ [sessionId],
+ )
+
+ const { rows } = await getPool().query<{ consumed_at: Date }>(
+ 'SELECT consumed_at FROM public.bankid_consumed_sessions WHERE session_id = $1',
+ [sessionId],
+ )
+ expect(rows[0]!.consumed_at).toBeInstanceOf(Date)
+ })
+
+ it('has RLS on with no policies, so authenticated users see nothing', async () => {
+ const { rows: flags } = await getPool().query<{ relrowsecurity: boolean }>(
+ `SELECT c.relrowsecurity
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE n.nspname = 'public' AND c.relname = 'bankid_consumed_sessions'`,
+ )
+ expect(flags[0]!.relrowsecurity).toBe(true)
+
+ const { rows: policies } = await getPool().query<{ n: number }>(
+ `SELECT count(*)::int AS n FROM pg_policies
+ WHERE schemaname = 'public' AND tablename = 'bankid_consumed_sessions'`,
+ )
+ expect(policies[0]!.n).toBe(0)
+
+ // And the guard actually bites for a real user, not just on paper: the
+ // handlers write through the service role, which bypasses RLS.
+ const sessionId = `sess-${randomUUID()}`
+ await getPool().query(
+ 'INSERT INTO public.bankid_consumed_sessions (session_id) VALUES ($1)',
+ [sessionId],
+ )
+
+ const userId = await insertAuthUser()
+ await withUserContext(userId, async (client) => {
+ const { rows } = await client.query<{ n: number }>(
+ 'SELECT count(*)::int AS n FROM public.bankid_consumed_sessions WHERE session_id = $1',
+ [sessionId],
+ )
+ expect(rows[0]!.n).toBe(0)
+ })
+ })
+})