fix(security): audit remediation 2026-09-01: api_keys identity, viewer gates, OAuth binding, XSS, MFA gate (#2155)

* fix(security): bind api_keys to the caller, lock hash-as-bearer RPCs and provider token tables

Security audit 2026-09-01, critical items.

- api_keys INSERT requires user_id = auth.uid() again (an admin could
  forge a key for any co-member and act as them in every company they
  belong to); SELECT is own-keys-or-admin; a BEFORE trigger freezes the
  identity and credential columns against user-session UPDATEs.
- rotate_mcp_refresh_token and validate_and_increment_api_key become
  service_role only: they match rows by a presented SHA-256, so a hash
  readable by co-members was a bearer credential.
- validate_and_increment_api_key fails closed when the key's user is no
  longer a member of the key's company.
- provider_consent_tokens and provider_otc: the DELETE policies collapsed
  to "caller has any team row" (correlated subquery on a non-existent
  team_members.company_id). All member policies dropped; service_role
  only, matching every existing code path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): role gates, ownership guards and posting integrity in the database

Security audit 2026-09-01, high items at the database layer.

- One table-level guard, enforce_company_writer_role(), blocks the
  read-only viewer role on 55 company-scoped tables including through
  the 15 membership-only SECURITY DEFINER writers. Keyed on the JWT role
  claim so it fires inside definer bodies; no-op for service_role and
  trigger cascades.
- company_members user_id/company_id immutable from user sessions;
  invitations can never grant owner; team_members gains a transition
  guard (admins keep non-owner role moves); companies team_id and
  archiving are owner-only and team attachment needs team membership.
- Direct statements (current_user = authenticated) can no longer insert
  posted headers, add lines under posted verifikat, or post a draft with
  a voucher number the sequence never issued. Sanctioned RPCs run as the
  definer and are untouched; the engine's own draft-then-post shapes
  still pass.
- create_document_version refuses viewers and foreign storage paths;
  validate_version_chain needs membership and loses anon EXECUTE;
  match_documents / match_booking_templates lose anon; cron maintenance
  RPCs become service_role only; the production-only
  seed_asset_categories is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* build: pin tsx as an exact devDependency instead of fetching it with npx at build time

prebuild ran "npx tsx" with no lockfile entry, so every Vercel, Docker
and CI build downloaded tsx@latest and its transitive tree from the
registry with no integrity check, inside the build environment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): refuse the viewer role on API-key and MCP write paths

The v1 wrapper and the MCP company routing checked company membership
but never role, and both run as service role, so a read-only viewer
holding an API key could post vouchers and change settings through the
API. Mutating methods and non-read scopes now return 403 ROLE_READ_ONLY
for viewers on v1; MCP write tools refuse viewers the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): stop serving uploaded SVG, XML and HTML as executable content on the app origin

Uploads persisted the browser-declared mime type and the inline proxy
served it verbatim, sandboxing only text/html; the storage proxy
forwarded the uploader's Content-Type. Any writer, or any Peppol sender,
could plant a scripted SVG or XHTML that executed on app.gnubok.se.

- inline route: allow-list of natively safe types (PDF, raster images)
  served as before; everything else gets the opaque sandbox CSP.
- storage proxy: octet-stream + attachment + sandbox unless the DB
  mime for the key is on the allow-list.
- document-service: the stored mime is the magic-byte validated type.
- logo upload: magic-byte validation, SVG refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): byrå brand logo upload decides the type by magic bytes and drops SVG

Same pattern as the company logo route: the logos bucket is public, so a
scripted SVG (or anything declared as an image) must never land there.
The upload pickers stop advertising SVG.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind Enable Banking, Stripe and WooCommerce callbacks to the initiating user

The callbacks resolved the pending row by oauth_state alone, so a
victim who completed an attacker-initiated consent had their bank
account, merchant account or store attached to the attacker's company.
requireFlowInitiator() now requires the cookie session of the user who
started the flow: no session redirects to login with the callback URL
preserved, a different user is refused and nothing is exchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): guard tenant-controlled outbound fetches and surface the disabled rate limiter

WooCommerce and Shopify syncs fetched a member-editable store URL with
plain fetch() and redirect following under the service role, and the
invoice PDF renderer fetched company_settings.logo_url unguarded. All
three go through a new safeFetch() (public-IP validation via url-guard,
https only, redirect: 'manual', body size cap) and re-normalise the
stored host at use time. checkRateLimit() keeps failing open on hosted
but logs one error per process when Upstash is not configured and
exports isRateLimiterConfigured().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): decide the API MFA gate from server-authenticated factors, not the session cookie

getAuthenticatorAssuranceLevel() without arguments derives nextLevel
from session.user.factors, which comes from the unsigned sb-*-auth-token
cookie. Deleting factors from the cookie made an enrolled account look
like it had nothing to step up to, on every /api route and in
requireAuth. Both gates now read factors from the getUser() result or
listFactors() and the level from the verified JWT claim, and fail closed
on errors. Page-branch gate hardened the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind Fortnox/Visma, Gmail and Skatteverket callbacks to the initiating user

The arcim-migration callback exchanged the provider code onto whatever
consent the one-time state named, with no check of who completed the
flow and no org-number comparison, so a phished Fortnox admin handed
their ledger to the attacker's company. provider_otc now records the
initiating user (migration 20260902100000); the callback requires that
session and, after the exchange, refuses a provider company whose org
number differs from the consent's company. The Gmail and Skatteverket
callbacks enforce the same initiator check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): BankID signup confirms the email before linking the identity

Signup created an email-confirmed, MFA-exempt account for any address
the caller typed and returned a magic link, so an attacker could
pre-register a victim's email and keep a permanent BankID login into the
account the victim later adopted. The user is now created unconfirmed,
the identity carries email_verified_at NULL (migration 20260902101000),
bankid_linked is not set until the mailed confirmation is clicked, and
BankID login of a pending identity is refused with the confirmation
re-sent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(security): bind MCP OAuth redirect URIs to the consenting user and cap scopes

A user-registered redirect URI was allowlisted globally, the consent page
named no client, and all scopes were pre-checked, so one phishing link
handed an attacker a full-scope key for the victim's company. Registered
URIs now resolve only for the registrant or a colleague sharing a
company; the consent page shows the client identity and redirect host;
non-built-in clients default to read-only pre-checks; scopes are capped
by the user's role (viewer: read only) at consent and at /token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(auth): client follow-ups for BankID confirmation, callback mismatch copy and decision log

- register client handles the new confirmation_sent response from BankID
  signup with the existing inbox screen instead of calling verifyOtp.
- BankID login surfaces the email_unconfirmed explanation.
- WooCommerce settings map woocommerce_error=wrong_user to its own copy.
- Logo help text no longer advertises SVG.
- DECISIONS.md records the audit remediation choices.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp-oauth): literal SoD columns in the api_keys insert so the phantom-column scanner resolves them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(logo): type the upload fixtures as Uint8Array<ArrayBuffer> so they are valid BlobParts

Fixes the typecheck ratchet on PR #2155 and ratchets the baseline down
by the one legacy error the change removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 11:38:30 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 6e8d76a9cb
commit 18cbc4c30a
92 changed files with 10355 additions and 657 deletions
@@ -0,0 +1,255 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
import { createMockRequest } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
/**
* Binds the OAuth callback to the user who STARTED the flow.
*
* oauth-callback-state.test.ts pins that the callback trusts nothing but the
* server-written state row. That row proves the callback belongs to a flow we
* started; it does not say who is finishing it. The authorize URL is
* shareable, so a victim lured into approving a Fortnox/Visma consent that an
* attacker started would have their provider account bound to the attacker's
* consent, and the attacker's next migration would import the victim's ledger.
*
* The state row now records the initiator (provider_otc.user_id, written by
* generateOtc from /connect) and the callback requires the completing
* browser's own session to be that user before the code is exchanged. The
* binding helper is the real one; only the cookie session behind it is faked.
*/
vi.mock('../lib/migration-orchestrator', () => ({
executeMigration: vi.fn().mockResolvedValue({}),
}))
vi.mock('../lib/provider-client', () => ({
createConsent: vi.fn(),
getConsent: vi.fn(),
listConsents: vi.fn(),
generateOtc: vi.fn(),
consumeOAuthState: vi.fn(),
getAuthUrl: vi.fn(),
exchangeAuthToken: vi.fn(),
submitProviderToken: vi.fn(),
acceptConsent: vi.fn(),
deleteConsent: vi.fn(),
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
// Mirrors the real constructor and message so the callback's mapping from
// this error to the Swedish registry sentence is exercised for real.
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {
constructor(
public readonly expectedOrgNumber: string,
public readonly actualOrgNumber: string,
public readonly actualCompanyName: string | null,
) {
super(
`Provider company mismatch: credentials open ${actualOrgNumber}, ` +
`but the target company is ${expectedOrgNumber}`,
)
}
},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
const { mockCreateClient } = vi.hoisted(() => ({ mockCreateClient: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createClient: mockCreateClient,
createServiceClient: vi.fn(),
}))
import { arcimMigrationExtension } from '../index'
import {
consumeOAuthState,
exchangeAuthToken,
generateOtc,
getAuthUrl,
listConsents,
createConsent,
ProviderCompanyMismatchError,
} from '../lib/provider-client'
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
const findRoute = (method: string, path: string) =>
(arcimMigrationExtension.apiRoutes ?? []).find((r) => r.method === method && r.path === path)!
const callbackHandler = findRoute('GET', '/callback').handler as RouteHandler
const connectHandler = findRoute('POST', '/connect').handler as RouteHandler
const APP_URL = 'https://app.example.test'
const STATE_REJECTED = 'Ingen giltig migrationssession hittades'
/** The browser completing the callback is signed in as `userId` (or nobody). */
function useSession(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
error: null,
}),
},
})
}
function callbackRequest(params: Record<string, string>) {
return createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/callback`, {
searchParams: params,
})
}
function connectCtx(userId = 'user-1'): ExtensionContext {
return {
supabase: { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: userId } } }) } },
companyId: 'company-1',
userId,
} as unknown as ExtensionContext
}
describe('GET /callback: the completing session must be the initiator', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.stubEnv('FORTNOX_REDIRECT_URI', '')
vi.stubEnv('VISMA_REDIRECT_URI', '')
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
;(exchangeAuthToken as Mock).mockResolvedValue({ success: true, consentId: 'consent-1' })
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('finalises when the browser session belongs to the user who started the flow', async () => {
useSession('user-1')
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).toHaveBeenCalledTimes(1)
expect(exchangeAuthToken).toHaveBeenCalledWith(
'consent-1',
'fortnox',
'provider-auth-code',
`${APP_URL}/api/extensions/ext/arcim-migration/callback`,
)
expect(html).toContain('Anslutningen lyckades')
})
it('refuses a completion by a different signed-in user without exchanging the code', async () => {
// The victim (user-2) was lured into approving user-1's consent.
useSession('user-2')
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(html).toContain('Anslutningen misslyckades')
expect(html).toContain('annat användarkonto')
// The refused party learns nothing about the consent it tried to complete.
expect(html).not.toContain('consent-1')
// Error popups stay open (see oauth-callback-state.test.ts).
expect(html).not.toContain('window.close')
})
it('refuses a completion with no session at all and asks for a fresh connect', async () => {
useSession(null)
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
// The state is already spent (consumed atomically before the check), so
// the only way forward is a new connect, not a login-and-retry.
expect(consumeOAuthState).toHaveBeenCalledWith('otc')
expect(html).toContain('Logga in och starta om anslutningen')
expect(html).not.toContain('consent-1')
})
it('refuses a state row that records no initiator, with the generic state rejection', async () => {
// Rows minted before provider_otc.user_id existed: nobody to bind to.
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: null,
})
useSession('user-1')
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(mockCreateClient).not.toHaveBeenCalled()
expect(html).toContain(STATE_REJECTED)
})
it('reports valid tokens for the WRONG company in Swedish and never claims success', async () => {
useSession('user-1')
;(exchangeAuthToken as Mock).mockRejectedValue(
new ProviderCompanyMismatchError('5560160680', '5567037485', 'Annat Bolag AB'),
)
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(html).toContain('Anslutningen misslyckades')
expect(html).toContain('Uppgifterna gäller ett annat företag')
expect(html).not.toContain('Provider company mismatch')
expect(html).not.toContain('Anslutningen lyckades')
})
})
describe('POST /connect: the state row records who started the flow', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.stubEnv('FORTNOX_REDIRECT_URI', '')
vi.spyOn(console, 'error').mockImplementation(() => {})
;(generateOtc as Mock).mockResolvedValue({ code: 'otc-code-1' })
;(getAuthUrl as Mock).mockResolvedValue({ url: 'https://apps.fortnox.se/oauth-v1/auth?x=1' })
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('binds a first connect to the caller', async () => {
;(listConsents as Mock).mockResolvedValue([])
;(createConsent as Mock).mockResolvedValue({ id: 'consent-new' })
const res = await connectHandler(
createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/connect`, {
method: 'POST',
body: { provider: 'fortnox' },
}),
connectCtx('user-1'),
)
expect(res.status).toBe(200)
expect(generateOtc).toHaveBeenCalledWith('consent-new', 'user-1')
})
it('binds a reconnect of an existing consent to the caller too', async () => {
;(listConsents as Mock).mockResolvedValue([{ id: 'consent-1', provider: 'fortnox', status: 1 }])
const res = await connectHandler(
createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/connect`, {
method: 'POST',
body: { provider: 'fortnox', reconnect: true },
}),
connectCtx('user-1'),
)
expect(res.status).toBe(200)
expect(generateOtc).toHaveBeenCalledWith('consent-1', 'user-1')
})
})
@@ -42,6 +42,7 @@ vi.mock('../lib/provider-client', () => ({
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
@@ -52,7 +53,17 @@ vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
}))
// The callback also binds the completing browser session to the user recorded
// on the state row. That check has its own tests
// (oauth-callback-initiator.test.ts); here it always passes so these tests
// stay about the state token itself.
vi.mock('@/lib/auth/oauth-flow-binding', () => ({
requireFlowInitiator: vi.fn(),
FLOW_INITIATOR_MISMATCH_MESSAGE: 'initiator mismatch',
}))
import { arcimMigrationExtension } from '../index'
import { requireFlowInitiator } from '@/lib/auth/oauth-flow-binding'
import {
consumeOAuthState,
exchangeAuthToken,
@@ -74,6 +85,12 @@ const findRoute = (method: string, path: string) =>
const callbackHandler = findRoute('GET', '/callback').handler as RouteHandler
const previewHandler = findRoute('GET', '/preview').handler as RouteHandler
// Every state row below was minted by 'user-1', and 'user-1' is the one
// completing the flow. Set per test, after each describe's clearAllMocks.
beforeEach(() => {
;(requireFlowInitiator as Mock).mockResolvedValue({ ok: true, userId: 'user-1' })
})
const APP_URL = 'https://app.example.test'
/** The exact string the callback shows for every state failure. */
@@ -143,7 +160,7 @@ describe('GET /callback: OAuth state binding', () => {
it('rejects a replayed state: the second callback with the same token fails', async () => {
// First delivery consumes the row, second finds nothing left to consume.
;(consumeOAuthState as Mock)
.mockResolvedValueOnce({ consentId: 'consent-1', provider: 'fortnox' })
.mockResolvedValueOnce({ consentId: 'consent-1', provider: 'fortnox', userId: 'user-1' })
.mockResolvedValueOnce(null)
const first = await callbackHandler(
@@ -165,6 +182,7 @@ describe('GET /callback: OAuth state binding', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-owned-by-caller',
provider: 'visma',
userId: 'user-1',
})
// The token names a different consent and provider. It must be ignored:
@@ -228,6 +246,7 @@ describe('GET /callback: full-page fallback when there is no opener', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
@@ -258,6 +277,7 @@ describe('GET /callback: full-page fallback when there is no opener', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
@@ -372,6 +392,7 @@ describe('OAuth redirect_uri symmetry between authorize and exchange', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-new',
provider: 'fortnox',
userId: 'user-1',
})
await callbackHandler(
@@ -421,6 +442,7 @@ describe('GET /callback: error popup stays open, success popup closes', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
@@ -515,6 +537,7 @@ describe('GET /callback: the spent callback URL cannot come back', () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
const res = await callbackHandler(
@@ -0,0 +1,164 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
/**
* exchangeAuthToken() must refuse valid OAuth tokens that open the WRONG
* company.
*
* Bokio and WINT already had this guard in submitProviderToken: a token that
* works plus the wrong company imports a foreign legal entity's customers,
* suppliers and invoices into this ledger with no error at all. The OAuth
* providers (Fortnox, Visma) had nothing: whichever company the user (or
* whoever lured them) signed in to at the provider was bound to the consent.
*
* Rule under test: only a confident mismatch blocks. A missing org number on
* either side, or a failed company-information call, is not evidence and the
* exchange completes as before.
*/
vi.mock('@/lib/providers/oauth-config', () => ({
getOAuthConfig: vi.fn(() => ({
clientId: 'client-id',
clientSecret: 'client-secret',
redirectUri: 'https://app.example/api/extensions/ext/arcim-migration/callback',
})),
}))
vi.mock('@/lib/providers/fortnox/oauth', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/providers/fortnox/oauth')>()),
exchangeFortnoxCode: vi.fn(),
}))
vi.mock('@/lib/providers/provider-data-fetcher', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/providers/provider-data-fetcher')>()),
fetchCompanyInfoDirect: vi.fn(),
}))
let serviceClient: ReturnType<typeof createQueuedMockSupabase>
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceClient.supabase,
createClient: vi.fn(),
}))
import { exchangeAuthToken, ProviderCompanyMismatchError } from '../lib/provider-client'
import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth'
import { fetchCompanyInfoDirect } from '@/lib/providers/provider-data-fetcher'
// Both pass normalizeOrgNumber's Luhn check: Spotify AB and Ericsson.
const TARGET_ORG = '5560160680'
const OTHER_ORG = '5567037485'
const TOKENS = { access_token: 'access-1', refresh_token: 'refresh-1', expires_in: 3600 }
/**
* Queue results in exchangeAuthToken's `from` order: provider_consents
* (company_id), companies (org_number), then the token upsert and the status
* update (whose results are not read).
*/
function useDb(consentCompanyId: string | null, targetOrgNumber: string | null) {
serviceClient = createQueuedMockSupabase()
serviceClient.enqueueMany([
{ data: consentCompanyId ? { company_id: consentCompanyId } : null },
{ data: { org_number: targetOrgNumber } },
])
return serviceClient
}
describe('exchangeAuthToken: provider company must match the consent company', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
;(exchangeFortnoxCode as Mock).mockResolvedValue(TOKENS)
})
it('refuses tokens for a different company and stores nothing', async () => {
const db = useDb('company-1', '556016-0680')
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({
companyName: 'Annat Bolag AB',
organizationNumber: '556703-7485',
})
const error = await exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb').catch((e) => e)
expect(error).toBeInstanceOf(ProviderCompanyMismatchError)
expect((error as ProviderCompanyMismatchError).expectedOrgNumber).toBe(TARGET_ORG)
expect((error as ProviderCompanyMismatchError).actualOrgNumber).toBe(OTHER_ORG)
expect((error as ProviderCompanyMismatchError).actualCompanyName).toBe('Annat Bolag AB')
// The one-shot code was exchanged (that is how we learned the company),
// but nothing about the result may land in the database.
expect(db.findCall('provider_consent_tokens', 'upsert')).toBeUndefined()
expect(db.findCall('provider_consents', 'update')).toBeUndefined()
})
it('checks the company the token opens, using the fresh access token', async () => {
useDb('company-1', TARGET_ORG)
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({
companyName: 'Rätt Bolag AB',
organizationNumber: TARGET_ORG,
})
await exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')
expect(fetchCompanyInfoDirect).toHaveBeenCalledWith('fortnox', 'access-1')
})
it('stores the tokens and accepts the consent when the org numbers agree', async () => {
const db = useDb('company-1', '556016-0680')
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({
companyName: 'Rätt Bolag AB',
organizationNumber: TARGET_ORG,
})
await expect(exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')).resolves.toEqual({
success: true,
consentId: 'consent-1',
})
const upsert = db.findCall('provider_consent_tokens', 'upsert')?.[0] as Record<string, unknown>
expect(upsert).toMatchObject({
consent_id: 'consent-1',
provider: 'fortnox',
access_token: 'access-1',
refresh_token: 'refresh-1',
})
expect(db.findCall('provider_consents', 'update')?.[0]).toEqual({ status: 1 })
})
it('does not block when the provider reports no org number', async () => {
const db = useDb('company-1', TARGET_ORG)
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({ companyName: 'Namnlöst AB' })
await exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')
expect(db.findCall('provider_consent_tokens', 'upsert')).toBeDefined()
// Nothing to compare against: the target company is not even looked up.
expect(db.findCall('companies', 'select')).toBeUndefined()
})
it('does not block when the Accounted company has no org number', async () => {
const db = useDb('company-1', null)
;(fetchCompanyInfoDirect as Mock).mockResolvedValue({
companyName: 'Annat Bolag AB',
organizationNumber: OTHER_ORG,
})
await exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')
expect(db.findCall('provider_consent_tokens', 'upsert')).toBeDefined()
})
it('does not block when the company-information call fails', async () => {
// A scope the app registration lacks, or a provider hiccup, is not a
// verdict on identity. The tokens are valid (the exchange succeeded).
const db = useDb('company-1', TARGET_ORG)
;(fetchCompanyInfoDirect as Mock).mockRejectedValue(new Error('403 forbidden'))
await expect(exchangeAuthToken('consent-1', 'fortnox', 'code', 'https://cb')).resolves.toEqual({
success: true,
consentId: 'consent-1',
})
expect(db.findCall('provider_consent_tokens', 'upsert')).toBeDefined()
})
})
@@ -81,7 +81,7 @@ describe('consumeOAuthState', () => {
it('consumes the state row with one conditional UPDATE, not a read then a write', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1' } },
{ data: { consent_id: 'consent-1', user_id: 'user-1' } },
{ data: { provider: 'fortnox' } },
])
@@ -97,21 +97,22 @@ describe('consumeOAuthState', () => {
expect(findOp(otcCall, 'is', 'used_at')?.[1]).toEqual(['used_at', null])
// Expiry is enforced in the same statement, not in JavaScript afterwards.
expect(findOp(otcCall, 'gt', 'expires_at')).toBeDefined()
expect(findOp(otcCall, 'select', 'consent_id')).toBeDefined()
expect(findOp(otcCall, 'select', 'consent_id, user_id')).toBeDefined()
})
it('returns the consent and the provider read from the server-side rows', async () => {
useResults([{ data: { consent_id: 'consent-1' } }, { data: { provider: 'visma' } }])
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'visma' } }])
await expect(consumeOAuthState('state-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'visma',
userId: 'user-1',
})
})
it('reads the provider from provider_consents, never from the caller', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1' } },
{ data: { consent_id: 'consent-1', user_id: 'user-1' } },
{ data: { provider: 'fortnox' } },
])
@@ -136,10 +137,11 @@ describe('consumeOAuthState', () => {
})
it('returns null on replay: the second consume of the same token loses', async () => {
useResults([{ data: { consent_id: 'consent-1' } }, { data: { provider: 'fortnox' } }])
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'fortnox' } }])
await expect(consumeOAuthState('one-time-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
// Replay: used_at is now set, so `is('used_at', null)` matches nothing.
@@ -147,8 +149,20 @@ describe('consumeOAuthState', () => {
await expect(consumeOAuthState('one-time-token')).resolves.toBeNull()
})
it('returns userId null for a row minted before the initiator column existed', async () => {
// The callback refuses these (nobody to bind the completion to); this
// function only has to report the absence honestly, never invent a user.
useResults([{ data: { consent_id: 'consent-1', user_id: null } }, { data: { provider: 'fortnox' } }])
await expect(consumeOAuthState('state-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'fortnox',
userId: null,
})
})
it('returns null when the consent behind a valid token is gone', async () => {
useResults([{ data: { consent_id: 'consent-1' } }, { data: null }])
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: null }])
await expect(consumeOAuthState('state-token')).resolves.toBeNull()
})
@@ -172,12 +186,15 @@ describe('generateOtc', () => {
const { calls } = useResults([{ data: null }])
const before = Date.now()
const { expiresAt } = await generateOtc('consent-1')
const { expiresAt } = await generateOtc('consent-1', 'user-1')
const after = Date.now()
expect(calls[0].table).toBe('provider_otc')
const inserted = findOp(calls[0], 'insert')?.[1][0] as { consent_id: string; expires_at: string }
const inserted = findOp(calls[0], 'insert')?.[1][0] as { consent_id: string; user_id: string; expires_at: string }
expect(inserted.consent_id).toBe('consent-1')
// The initiator travels with the row: the callback binds the completing
// session to it, so it must be written here and nowhere else.
expect(inserted.user_id).toBe('user-1')
const tenMinutes = 10 * 60 * 1000
const expiry = new Date(expiresAt).getTime()
+65 -7
View File
@@ -38,6 +38,11 @@ import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import type { ProviderName } from '@/lib/providers/types'
import { FORTNOX_DOCUMENT_SCOPES_APPROVED } from '@/lib/providers/fortnox/oauth'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import {
requireFlowInitiator,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '@/lib/auth/oauth-flow-binding'
import { classifyProviderError } from '@/lib/providers/with-provider-call'
import { getProviderResourceForbiddenMessage } from '@/lib/errors/get-error-message'
import { FortnoxApiError, fortnoxErrorMessage } from '@/lib/providers/fortnox/client'
@@ -54,6 +59,15 @@ const moduleLog = createLogger('extensions/arcim-migration')
const STATE_REJECTED_MESSAGE =
'Ingen giltig migrationssession hittades. Starta om anslutningen.'
/**
* A valid state reached the callback but the browser carries no session. The
* state is already spent by then (consumeOAuthState is atomic and runs first),
* so unlike the bank/Stripe callbacks there is nothing to resume after a
* login: the user starts the connect again from the wizard.
*/
const SESSION_MISSING_MESSAGE =
'Ingen inloggad session hittades i det här fönstret. Logga in och starta om anslutningen.'
/**
* Map known OAuth error codes from providers (Fortnox, Visma) to actionable
* Swedish guidance. Falls back to the raw provider message so we never hide
@@ -126,12 +140,14 @@ function resolveArcimCallbackUrl(provider: ArcimProvider | ProviderName): string
async function buildArcimOAuthUrl(
consentId: string,
provider: ArcimProvider,
initiatedByUserId: string,
options?: { documentScopes?: boolean },
): Promise<string> {
// Server-side state row: consent id, provider (via the consent), expiry and a
// consumed marker all live in provider_otc. The `state` handed to the provider
// is that row's opaque random primary key, nothing more.
const otc = await generateOtc(consentId)
// Server-side state row: consent id, provider (via the consent), the user who
// started the flow, expiry and a consumed marker all live in provider_otc.
// The `state` handed to the provider is that row's opaque random primary
// key, nothing more.
const otc = await generateOtc(consentId, initiatedByUserId)
const callbackUrl = resolveArcimCallbackUrl(provider)
@@ -346,7 +362,7 @@ export const arcimMigrationExtension: Extension = {
await ctx.settings.set('provider', provider)
}
if (providerInfo.authType === 'oauth') {
const authUrl = await buildArcimOAuthUrl(stale.id, provider, {
const authUrl = await buildArcimOAuthUrl(stale.id, provider, user.id, {
documentScopes: documentScopes === true,
})
return NextResponse.json({
@@ -431,7 +447,7 @@ export const arcimMigrationExtension: Extension = {
}
if (providerInfo.authType === 'oauth') {
const authUrl = await buildArcimOAuthUrl(consent.id, provider)
const authUrl = await buildArcimOAuthUrl(consent.id, provider, user.id)
return NextResponse.json({
consentId: consent.id,
@@ -674,7 +690,39 @@ export const arcimMigrationExtension: Extension = {
return respondWithError(STATE_REJECTED_MESSAGE)
}
const { consentId, provider } = resolvedState
const { consentId, provider, userId: initiatedByUserId } = resolvedState
// The state proves this callback belongs to a flow WE started; it
// says nothing about who is finishing it. Before the code is
// exchanged, the completing browser's own session must belong to the
// user recorded on the state row at connect time. Otherwise a victim
// lured into approving a consent someone else started has their
// Fortnox/Visma account bound to that someone's consent, and the
// next migration imports the victim's ledger into a stranger's
// company. Checked before callbackConsentId is set: a refused
// completion must not hand the consent id to whoever is refused.
if (!initiatedByUserId) {
// Row minted before provider_otc.user_id existed (or written
// outside generateOtc): nobody to bind to, so nobody may finish it.
// Such rows expire within 10 minutes of the deploy.
log.error('OAuth callback state carries no initiator; refusing', { consentId })
return respondWithError(STATE_REJECTED_MESSAGE)
}
const initiator = await requireFlowInitiator(request, initiatedByUserId, {
flow: 'arcim-migration.callback',
})
if (!initiator.ok) {
log.error('OAuth callback refused: completing session is not the initiator', {
consentId,
reason: initiator.reason,
})
return respondWithError(
initiator.reason === 'no_session'
? SESSION_MISSING_MESSAGE
: FLOW_INITIATOR_MISMATCH_MESSAGE,
)
}
callbackConsentId = consentId
// Must match the redirect_uri the authorization request was built
@@ -711,6 +759,16 @@ export const arcimMigrationExtension: Extension = {
})
} catch (error) {
log.error('OAuth callback exchange failed', error)
// Valid tokens for the WRONG company: exchangeAuthToken stored
// nothing. Show the user-facing sentence from the error registry
// (the same one /submit-token answers with for Bokio/WINT) instead
// of the English diagnostic on the error object.
if (error instanceof ProviderCompanyMismatchError) {
return respondWithError(
getErrorEntry('PROVIDER_COMPANY_MISMATCH')?.message_sv ?? error.message,
callbackConsentId,
)
}
const reason = error instanceof Error ? error.message : 'Okänt fel vid tokenutbyte.'
return respondWithError(reason, callbackConsentId)
}
@@ -28,8 +28,13 @@ import {
import { WintClient, WintApiError } from '@/lib/providers/wint/client'
import { loginWint, WintLoginRejectedError } from '@/lib/providers/wint/oauth'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { fetchCompanyInfoDirect } from '@/lib/providers/provider-data-fetcher'
import type { CompanyInformationDto } from '@/lib/providers/dto'
import { createLogger } from '@/lib/logger'
import type { ConsentRecord, OtcResponse } from '../types'
const log = createLogger('extensions/arcim-migration/provider-client')
// Singleton (holds the rate limiter): used to validate BL User-Keys at submit
const bjornLundenClient = new BjornLundenClient()
@@ -235,9 +240,15 @@ export async function deleteConsent(consentId: string): Promise<void> {
* to the provider's login page and back. OAuth guidance puts state/OTC
* lifetimes at 5-10 minutes; every extra minute widens the window in which a
* leaked or phished state can still be consumed.
*
* `initiatedByUserId` is the user who clicked connect. The row remembers it so
* the callback can insist that the SAME user's browser session completes the
* flow: the state proves the callback belongs to a flow we started, not that
* the person finishing it is the person who started it.
*/
export async function generateOtc(
consentId: string,
initiatedByUserId: string,
expiresInMinutes: number = 10,
): Promise<OtcResponse> {
const supabase = createServiceClient()
@@ -250,6 +261,7 @@ export async function generateOtc(
.insert({
code,
consent_id: consentId,
user_id: initiatedByUserId,
expires_at: expiresAt,
})
@@ -275,10 +287,14 @@ export async function generateOtc(
* Returns null for every failure mode: unknown/forged state, expired state,
* already-consumed state, deleted consent. Callers must not distinguish them,
* that distinction is exactly the oracle this function exists to remove.
*
* `userId` is the initiator recorded at generateOtc time (null only for rows
* minted before provider_otc.user_id existed). The callback compares it to the
* completing browser's session before exchanging the code.
*/
export async function consumeOAuthState(
state: string,
): Promise<{ consentId: string; provider: ProviderName } | null> {
): Promise<{ consentId: string; provider: ProviderName; userId: string | null } | null> {
const supabase = createServiceClient()
const now = new Date().toISOString()
@@ -288,7 +304,7 @@ export async function consumeOAuthState(
.eq('code', state)
.is('used_at', null)
.gt('expires_at', now)
.select('consent_id')
.select('consent_id, user_id')
.maybeSingle()
if (error || !consumed?.consent_id) {
@@ -308,6 +324,7 @@ export async function consumeOAuthState(
return {
consentId: consumed.consent_id as string,
provider: consent.provider as ProviderName,
userId: typeof consumed.user_id === 'string' ? consumed.user_id : null,
}
}
@@ -369,6 +386,13 @@ export async function exchangeAuthToken(
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
// The code is spent and the tokens are valid, but nothing so far ties the
// provider account they open to the Accounted company this consent belongs
// to: the user (or someone who lured them) may have signed in to a different
// Fortnox/Visma company. Same guard as Bokio/WINT in submitProviderToken:
// refuse BEFORE anything is stored, so a foreign ledger never gets imported.
await assertProviderCompanyMatchesConsent(supabase, consentId, provider, tokenResponse.access_token)
// Store tokens
await supabase
.from('provider_consent_tokens')
@@ -389,6 +413,61 @@ export async function exchangeAuthToken(
return { success: true, consentId }
}
/**
* Compare the org number of the company the freshly issued token opens with
* the org number of the Accounted company that owns the consent.
*
* Only a confident mismatch blocks (throws ProviderCompanyMismatchError). A
* missing org number on either side is not evidence of anything: Accounted
* allows companies without one, a provider response can omit it, and the
* company-information call itself can fail for reasons unrelated to identity
* (scope not granted on this app registration, provider hiccup). Those cases
* fall through and the connect completes exactly as before.
*/
async function assertProviderCompanyMatchesConsent(
supabase: ReturnType<typeof createServiceClient>,
consentId: string,
provider: ProviderName,
accessToken: string,
): Promise<void> {
let info: CompanyInformationDto | null
try {
info = await fetchCompanyInfoDirect(provider, accessToken)
} catch (error) {
log.warn('provider company information unavailable after OAuth exchange; org-number check skipped', {
provider,
consentId,
reason: error instanceof Error ? error.message : 'unknown',
})
return
}
const providerOrgNumber = normalizeOrgNumber(info?.organizationNumber)
if (!providerOrgNumber) return
const { data: consent } = await supabase
.from('provider_consents')
.select('company_id')
.eq('id', consentId)
.maybeSingle()
if (!consent?.company_id) return
const { data: targetCompany } = await supabase
.from('companies')
.select('org_number')
.eq('id', consent.company_id as string)
.maybeSingle()
const targetOrgNumber = normalizeOrgNumber(targetCompany?.org_number)
if (targetOrgNumber && providerOrgNumber !== targetOrgNumber) {
throw new ProviderCompanyMismatchError(
targetOrgNumber,
providerOrgNumber,
info?.companyName?.trim() || null,
)
}
}
export async function submitProviderToken(
consentId: string,
provider: ProviderName,
@@ -0,0 +1,154 @@
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
/**
* The Gmail OAuth callback must be completed by the user who started it.
*
* The signed state carries userId + companyId and proves the flow was started
* by us for that user. It does not prove that the browser now finishing it is
* that user: Google's authorize URL is shareable, so a victim lured into
* approving a consent someone else started would have THEIR mailbox saved
* (with the service client, no RLS) under the initiator's company. The
* callback now binds the completion to the initiator's own cookie session
* before the code is exchanged. The binding helper is the real one; only the
* session behind it is faked.
*/
vi.mock('@/lib/mail-search/service', () => ({ registerMailSearchService: vi.fn() }))
vi.mock('../lib/search-service', () => ({ GmailSearchService: class GmailSearchService {} }))
vi.mock('@/lib/auth/api-keys', () => ({ createServiceClientNoCookies: vi.fn(() => ({})) }))
vi.mock('../lib/google-oauth', () => ({
buildAuthorizationUrl: vi.fn(),
exchangeCodeForTokens: vi.fn(),
getGoogleOAuthEnv: vi.fn(() => ({})),
isGoogleMailConfigured: vi.fn(() => true),
}))
vi.mock('../lib/connections', () => ({
disconnect: vi.fn(),
listConnections: vi.fn(),
saveConnection: vi.fn(),
}))
const { mockCreateClient } = vi.hoisted(() => ({ mockCreateClient: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createClient: mockCreateClient,
createServiceClient: vi.fn(),
}))
import { mailExtension } from '../index'
import { createOAuthState } from '../lib/crypto'
import { exchangeCodeForTokens } from '../lib/google-oauth'
import { saveConnection } from '../lib/connections'
const APP_URL = 'https://app.example'
const CALLBACK_PATH = '/api/extensions/ext/mail/oauth/callback'
const callbackRoute = () =>
mailExtension.apiRoutes!.find((r) => r.method === 'GET' && r.path === '/oauth/callback')!
/** The browser completing the callback is signed in as `userId` (or nobody). */
function useSession(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
error: null,
}),
},
})
}
function callbackRequest(state: string) {
const url = new URL(`${APP_URL}${CALLBACK_PATH}`)
url.searchParams.set('code', 'google-code')
url.searchParams.set('state', state)
return new Request(url.toString())
}
describe('mail GET /oauth/callback: the completing session must be the initiator', () => {
let state: string
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
// 32 bytes of hex so createOAuthState/verifyOAuthState use a real key.
vi.stubEnv('MAIL_TOKEN_ENCRYPTION_KEY', '00'.repeat(32))
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
state = createOAuthState('user-1', 'company-1')
;(exchangeCodeForTokens as Mock).mockResolvedValue({
refreshToken: 'refresh-1',
accessToken: 'access-1',
expiresAt: '2030-01-01T00:00:00Z',
email: 'ekonomi@example.se',
scopes: 'gmail.readonly',
})
;(saveConnection as Mock).mockResolvedValue(undefined)
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('saves the grant for the state user when the session is that user', async () => {
useSession('user-1')
const res = await callbackRoute().handler(callbackRequest(state))
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe(`${APP_URL}/settings/mail?mail=connected`)
expect(exchangeCodeForTokens).toHaveBeenCalledTimes(1)
expect(saveConnection).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: 'company-1',
userId: 'user-1',
provider: 'gmail',
emailAddress: 'ekonomi@example.se',
}),
)
})
it('refuses a completion by a different signed-in user: no exchange, no save', async () => {
// The victim (user-2) was lured into approving user-1's consent.
useSession('user-2')
const res = await callbackRoute().handler(callbackRequest(state))
expect(res.status).toBe(307)
expect(res.headers.get('location')).toBe(`${APP_URL}/settings/mail?mail=mismatch`)
expect(exchangeCodeForTokens).not.toHaveBeenCalled()
expect(saveConnection).not.toHaveBeenCalled()
})
it('sends a session-less completion to login with the callback as next, saving nothing', async () => {
useSession(null)
const res = await callbackRoute().handler(callbackRequest(state))
expect(res.status).toBe(307)
const location = new URL(res.headers.get('location') as string)
expect(location.origin).toBe(APP_URL)
expect(location.pathname).toBe('/login')
// Same-origin relative path + query: the only form the login page's
// safeReturnTo accepts. Signing in re-runs the callback with the same
// code and (still unexpired) state.
const next = location.searchParams.get('next') as string
expect(next.startsWith(`${CALLBACK_PATH}?`)).toBe(true)
expect(new URL(next, APP_URL).searchParams.get('state')).toBe(state)
expect(exchangeCodeForTokens).not.toHaveBeenCalled()
expect(saveConnection).not.toHaveBeenCalled()
})
it('still rejects a forged or expired state before ever reading the session', async () => {
useSession('user-1')
const res = await callbackRoute().handler(callbackRequest('not-a-real-state'))
expect(res.headers.get('location')).toBe(`${APP_URL}/settings/mail?mail=expired`)
expect(mockCreateClient).not.toHaveBeenCalled()
expect(saveConnection).not.toHaveBeenCalled()
})
})
+18
View File
@@ -12,6 +12,7 @@ import {
} from './lib/google-oauth'
import { disconnect, listConnections, saveConnection } from './lib/connections'
import { resolveCallbackOrigin } from './lib/callback-origin'
import { requireFlowInitiator } from '@/lib/auth/oauth-flow-binding'
// Registered as soon as the extension loads, so the receipt hunt can search
// mail without core ever importing from @/extensions.
@@ -78,6 +79,23 @@ export const mailExtension: Extension = {
const verified = verifyOAuthState(state)
if (!verified) return NextResponse.redirect(`${settingsUrl}?mail=expired`)
// The signed state proves the flow was started by verified.userId for
// verified.companyId; it does not prove that the browser now finishing
// it is that user. The grant is written for the state's user and
// company with the service client, so without this check a victim
// lured into approving a Google consent someone else started would
// have THEIR mailbox attached to that someone's company. Checked
// before the code exchange so a refused flow burns nothing.
const initiator = await requireFlowInitiator(request, verified.userId, {
flow: 'mail.oauth-callback',
})
if (!initiator.ok) {
// No session: sign in and the callback re-runs with the same code
// and state (the state is stateless and still within its TTL).
if (initiator.reason === 'no_session') return initiator.response
return NextResponse.redirect(`${settingsUrl}?mail=mismatch`)
}
try {
const origin = resolveCallbackOrigin(url.origin)
const env = getGoogleOAuthEnv(origin)
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ALL_SCOPES } from '@/lib/auth/scope-catalog'
import {
addCompanyToNextHint,
addCompanyToTopLevelNext,
assertMcpCompanyWriteAccess,
extractRequestedCompany,
isCompanyDependentTool,
isTenantWriteScope,
projectToolInputSchema,
resolveMcpCompanyContext,
} from '../company-routing'
@@ -248,6 +250,47 @@ describe('MCP company routing', () => {
expect(() => assertMcpCompanyWriteAccess(context, 'webhooks:manage')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
expect(() => assertMcpCompanyWriteAccess(context, 'reconciliation:signoff')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
})
it('names the read-only role and the refused scope in the viewer refusal', () => {
const context = { companyId: OTHER_COMPANY_ID, role: 'viewer' as const, isDefault: false }
expect(() => assertMcpCompanyWriteAccess(context, 'bookkeeping:write')).toThrow(
expect.objectContaining({
code: 'FORBIDDEN',
message: expect.stringMatching(/read-only \(viewer\).*"bookkeeping:write"/),
})
)
})
it.each(['owner', 'admin', 'member'] as const)('lets a %s through on every scope', (role) => {
const context = { companyId: OTHER_COMPANY_ID, role, isDefault: false }
for (const scope of ALL_SCOPES) {
expect(() => assertMcpCompanyWriteAccess(context, scope)).not.toThrow()
}
})
it('classifies every non-:read scope in the catalogue as a tenant write', () => {
// Derived from scopeKind rather than an allowlist of suffixes, so a scope
// added with a new suffix is viewer-gated by default. Pin the split.
const writes = ALL_SCOPES.filter((scope) => isTenantWriteScope(scope))
const reads = ALL_SCOPES.filter((scope) => !isTenantWriteScope(scope))
expect(reads.length).toBeGreaterThan(0)
expect(reads.every((scope) => scope.endsWith(':read'))).toBe(true)
expect(writes.every((scope) => !scope.endsWith(':read'))).toBe(true)
expect(writes).toEqual(
expect.arrayContaining([
'invoices:write',
'pending_operations:approve',
'webhooks:manage',
'reconciliation:signoff',
])
)
expect(isTenantWriteScope(undefined)).toBe(false)
})
it('keeps company context in follow-up tool hints', () => {
@@ -0,0 +1,192 @@
/**
* Read-only role gate on the MCP tools/call path.
*
* The MCP surface runs as the service role, so RLS never sees the caller's
* company role. The dispatcher (server.ts, tools/call) therefore calls
* assertMcpCompanyWriteAccess() right after resolveMcpCompanyContext() for
* every company-scoped call. A `viewer` membership must be refused on every
* tool that requires a non-:read scope, even when the KEY carries that scope
* (effective permission = key scopes intersected with the user's role), and
* must pass unchanged on read tools. The bridge tool (gnubok_call_tool) is
* rewritten to the inner tool before routing, so the same gate applies there.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events/bus'
// Everything the hoisted vi.mock factories reference must itself be hoisted.
const mocks = vi.hoisted(() => ({
companyId: '11111111-1111-4111-8111-111111111111',
role: 'viewer' as string,
/** Every table the dispatcher or a tool touched, in order. */
tables: [] as string[],
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
// Generic query chain: every builder method returns the chain, awaiting it
// yields an empty result. Enough for read tools that tolerate `data: null`.
const emptyChain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
}
return () => emptyChain
},
},
)
const membershipChain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: { company_id: mocks.companyId, role: mocks.role }, error: null })
}
return () => membershipChain
},
},
)
return {
...actual,
extractBearerToken: vi.fn().mockReturnValue('test-token'),
// A LIVE key holding both a write and a read scope, so the scope gate
// passes and the role gate is what decides.
validateApiKey: vi.fn().mockResolvedValue({
userId: 'user-viewer',
companyId: mocks.companyId,
scopes: ['customers:write', 'reports:read'],
apiKeyId: 'key-1',
apiKeyName: 'Viewer Key',
mode: 'live',
}),
createServiceClientNoCookies: vi.fn(() => ({
from: (table: string) => {
mocks.tables.push(table)
return table === 'company_members' ? membershipChain : emptyChain
},
rpc: () => emptyChain,
})),
}
})
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
})
// Seat gate entitled: a viewer is a non-owner, so the gate would otherwise
// read companies + capability_grants through the empty chain above.
vi.mock('@/lib/entitlements/multi-user', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/multi-user')>()
return {
...actual,
getMultiUserState: vi.fn().mockResolvedValue({ state: 'entitled', graceEndsAt: null }),
}
})
import { handleMcpRequest } from '../server'
function mcpToolCall(name: string, args: Record<string, unknown> = {}): Request {
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name, arguments: args },
}),
})
}
async function parsedToolResult(
response: Response,
): Promise<{ isError: boolean; payload: Record<string, unknown> }> {
const json = await response.json()
const result = json.result as { isError?: boolean; content: { text: string }[] }
return { isError: result.isError === true, payload: JSON.parse(result.content[0].text) }
}
describe('MCP read-only role gate (viewer)', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mocks.role = 'viewer'
mocks.tables.length = 0
})
it('refuses a viewer on a write tool before execute(), even though the key holds the scope', async () => {
const result = await parsedToolResult(
await handleMcpRequest(
mcpToolCall('gnubok_create_customer', { name: 'Kund AB', customer_type: 'company' }),
),
)
expect(result.isError).toBe(true)
const error = result.payload.error as Record<string, unknown>
expect(error.code).toBe('FORBIDDEN')
expect(String(error.message_en)).toMatch(/read-only \(viewer\)/)
expect(String(error.message_en)).toContain('"customers:write"')
// Only the membership lookup ran: nothing tenant-shaped was touched.
expect(mocks.tables).toEqual(['company_members'])
})
it('routes a viewer read through the gnubok_call_tool bridge unchanged', async () => {
// The bridge is read-only by design (call-tool-bridge.test.ts): writes
// must be named directly, so the role gate has nothing to add there. What
// matters is that a bridged read still resolves the company and runs.
const result = await parsedToolResult(
await handleMcpRequest(
mcpToolCall('gnubok_call_tool', { tool: 'gnubok_list_fiscal_periods', arguments: {} }),
),
)
expect(result.isError).toBe(false)
expect(result.payload).toMatchObject({ periods: [], count: 0 })
expect(mocks.tables[0]).toBe('company_members')
expect(mocks.tables).toContain('fiscal_periods')
})
it('lets a viewer run a read tool unchanged', async () => {
const result = await parsedToolResult(
await handleMcpRequest(mcpToolCall('gnubok_list_fiscal_periods')),
)
expect(result.isError).toBe(false)
expect(result.payload).toMatchObject({ periods: [], count: 0 })
expect(mocks.tables).toContain('fiscal_periods')
})
it('lets a member through the role gate on the same write tool', async () => {
mocks.role = 'member'
const telemetry = new Promise<{ errorKind: string | null }>((resolve) => {
const off = eventBus.on('mcp.tool_called', (payload) => {
off()
resolve(payload as unknown as { errorKind: string | null })
})
})
const result = await parsedToolResult(
await handleMcpRequest(
mcpToolCall('gnubok_create_customer', { name: 'Kund AB', customer_type: 'swedish_business' }),
),
)
// Whatever the empty-chain stub makes execute() return, the role gate did
// not fire: the refusal kind is not an access denial and the message is
// not the read-only one.
expect((await telemetry).errorKind).not.toBe('company_access_denied')
if (result.isError) {
const error = result.payload.error as Record<string, unknown>
expect(error.code).not.toBe('FORBIDDEN')
expect(String(error.message_en)).not.toMatch(/read-only \(viewer\)/)
}
})
})
@@ -1,5 +1,8 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ApiKeyScope } from '@/lib/auth/api-keys'
// Pure data module (no server imports): the same classifier the settings UI
// and the v1 REST wrapper use, so "what counts as a write" has one owner.
import { scopeKind } from '@/lib/auth/scope-catalog'
import { getMultiUserState, isMembershipDormant } from '@/lib/entitlements/multi-user'
import type { CompanyRole } from '@/types'
@@ -75,16 +78,22 @@ export function isCompanyDependentTool(toolName: string): boolean {
return !COMPANY_INDEPENDENT_TOOLS.has(toolName)
}
/**
* Does a tool that requires `scope` change tenant state?
*
* Every scope that is not `:read` counts: `:write`, `:approve`, `:manage` and
* `:signoff` today (sign-off attests on the company's behalf, a write for the
* role guard even though the scope is deliberately not `:write`), and any
* suffix added later. Deriving from `scopeKind` instead of an allowlist of
* suffixes means a new elevated scope is write-gated for viewers by default
* rather than silently open until someone remembers this function.
*
* `undefined` (a tool absent from TOOL_SCOPE_MAP) is not a tenant write: the
* unscoped tools are discovery, skills and the feedback channel, and
* strict-schemas.test.ts pins that every write-annotated tool has a scope.
*/
export function isTenantWriteScope(scope: ApiKeyScope | undefined): boolean {
return (
scope?.endsWith(':write') === true ||
scope?.endsWith(':approve') === true ||
scope?.endsWith(':manage') === true ||
// Sign-off attests on the company's behalf: a write for the role guard
// even though the scope is deliberately not `:write` (linking rows must
// not imply the right to attest).
scope?.endsWith(':signoff') === true
)
return scope !== undefined && scopeKind(scope) === 'write'
}
export function projectToolInputSchema(tool: ToolSchemaSource): Record<string, unknown> {
@@ -166,12 +175,27 @@ export async function resolveMcpCompanyContext(args: {
}
}
/**
* Read-only role gate for the MCP tools/call path.
*
* Called by the dispatcher (server.ts, tools/call) right after
* `resolveMcpCompanyContext` for every company-scoped call, including calls
* routed through the gnubok_call_tool bridge, and before execute(). The MCP
* surface runs as the service role, so RLS never sees the viewer: this is
* the only place the role is enforced for API-key callers. Read tools pass
* unchanged; a viewer's key with write scopes is still refused, because the
* key's scopes bound what the key MAY do and the role bounds what the user
* may do, and the effective permission is the intersection.
*/
export function assertMcpCompanyWriteAccess(
context: McpCompanyContext,
scope: ApiKeyScope | undefined
): void {
if (context.role === 'viewer' && isTenantWriteScope(scope)) {
throw codedError('FORBIDDEN', 'Write permission required for this company')
throw codedError(
'FORBIDDEN',
`This company membership is read-only (viewer): tools that require the "${scope}" scope change company data and are refused. Use read tools only, or ask a company owner or admin to change the role.`
)
}
}
@@ -8,10 +8,24 @@ import {
ShopifyApiError,
SHOPIFY_API_VERSION,
SHOPIFY_PAGE_SIZE,
INVALID_SHOP_DOMAIN_CODE,
UNSAFE_SHOP_URL_CODE,
type ShopifyCredentials,
type ShopifySession,
} from '../lib/api-client'
// The SSRF guard resolves DNS. Stub the validator (same seam the webhook
// dispatcher tests use) so tests are deterministic and offline; a dedicated
// test below flips it to a private-address verdict.
const guard = vi.hoisted(() => ({ validateUrl: vi.fn() }))
vi.mock('@/lib/webhooks/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/webhooks/url-guard')>()
return {
...actual,
validateWebhookUrl: (...args: unknown[]) => guard.validateUrl(...args),
}
})
const CREDS: ShopifyCredentials = {
shopDomain: 'minbutik.myshopify.com',
clientId: 'client-id',
@@ -34,6 +48,12 @@ const fetchMock = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', fetchMock)
guard.validateUrl.mockReset()
guard.validateUrl.mockImplementation(async (rawUrl: string) => ({
ok: true,
hostname: new URL(rawUrl).hostname,
resolvedAddresses: ['203.0.113.10'],
}))
})
afterEach(() => {
@@ -89,6 +109,9 @@ describe('exchangeAccessToken', () => {
client_secret: 'client-secret',
grant_type: 'client_credentials',
})
// Guarded transport: no redirect following, DNS check ran for this URL.
expect((init as RequestInit).redirect).toBe('manual')
expect(guard.validateUrl).toHaveBeenCalledWith(url, undefined)
})
it('classifies a non-retryable 4xx as revoked credentials', async () => {
@@ -111,6 +134,33 @@ describe('exchangeAccessToken', () => {
expect((error as ShopifyApiError).status).toBe(429)
expect(isRevokedCredentialsError(error)).toBe(false)
}, 15_000)
it('refuses a stored shop_domain that is not a myshopify.com host (edited outside the app) before any fetch', async () => {
for (const shopDomain of ['minbutik.se', '10.0.0.5', 'https://evil.example.com/x.myshopify.com', '']) {
const error = await exchangeAccessToken({ ...CREDS, shopDomain }).catch((e) => e)
expect(error).toBeInstanceOf(ShopifyApiError)
expect((error as ShopifyApiError).code).toBe(INVALID_SHOP_DOMAIN_CODE)
expect((error as ShopifyApiError).status).toBe(0)
// Not a credentials problem: the row is malformed, not revoked upstream.
expect(isRevokedCredentialsError(error)).toBe(false)
}
expect(fetchMock).not.toHaveBeenCalled()
})
it('refuses a myshopify.com host that resolves to a private address, without retrying', async () => {
guard.validateUrl.mockResolvedValue({
ok: false,
reason: 'private_address',
detail: 'Resolved address 10.1.2.3 for minbutik.myshopify.com is not publicly routable (private_address).',
})
const error = await exchangeAccessToken(CREDS).catch((e) => e)
expect(error).toBeInstanceOf(ShopifyApiError)
expect((error as ShopifyApiError).code).toBe(UNSAFE_SHOP_URL_CODE)
expect(fetchMock).not.toHaveBeenCalled()
expect(guard.validateUrl).toHaveBeenCalledTimes(1)
})
})
describe('shopifyGraphQL', () => {
@@ -126,6 +176,7 @@ describe('shopifyGraphQL', () => {
expect((init as RequestInit).headers).toMatchObject({
'X-Shopify-Access-Token': 'token-1',
})
expect((init as RequestInit).redirect).toBe('manual')
})
it('retries a THROTTLED response before surfacing data', async () => {
@@ -154,6 +205,29 @@ describe('shopifyGraphQL', () => {
expect(isRevokedCredentialsError(error)).toBe(true)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('treats a redirect as a failure, not a hop, and does not retry it', async () => {
fetchMock.mockResolvedValueOnce(
new Response(null, { status: 307, headers: { Location: 'http://10.0.0.1/' } }),
)
const error = await shopifyGraphQL(SESSION, 'query { ok }').catch((e) => e)
expect(error).toBeInstanceOf(ShopifyApiError)
expect((error as ShopifyApiError).code).toBe(UNSAFE_SHOP_URL_CODE)
expect(isRevokedCredentialsError(error)).toBe(false)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('refuses a session whose shop domain is not a myshopify.com host before any fetch', async () => {
const error = await shopifyGraphQL(
{ ...SESSION, shopDomain: 'internal.example' },
'query { ok }',
).catch((e) => e)
expect((error as ShopifyApiError).code).toBe(INVALID_SHOP_DOMAIN_CODE)
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('listOrdersPage', () => {
+62 -3
View File
@@ -1,8 +1,16 @@
import { isUnsafeUrlError, safeFetch } from '@/lib/http/safe-fetch'
import type { ShopifyOrder, ShopifyShopInfo } from '../types'
/**
* Minimal Shopify GraphQL Admin API client for the order feed.
*
* The shop domain is tenant input that the server connects to, and members
* can write `shopify_connections.shop_domain` directly through PostgREST
* (bypassing the connect route's normalisation), so every request here
* re-normalises the stored domain to `<handle>.myshopify.com` and goes
* through `safeFetch`: public addresses only, checked at request time, and no
* redirects followed.
*
* Auth is the client credentials grant: the merchant creates a custom app in
* their own Shopify Dev Dashboard (the admin-created custom apps with
* revealable shpat_ tokens were discontinued 2026-01-01) and pastes the app's
@@ -91,12 +99,56 @@ export function normalizeShopDomain(input: string): string | null {
return /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(value) ? value : null
}
/** Error code on a ShopifyApiError when the stored shop domain fails re-normalisation. */
export const INVALID_SHOP_DOMAIN_CODE = 'accounted_invalid_shop_domain'
/** Error code on a ShopifyApiError when the SSRF guard refused to connect. */
export const UNSAFE_SHOP_URL_CODE = 'accounted_unsafe_shop_url'
/**
* Re-run the connect-time normalisation on the STORED shop domain at use
* time and return the https origin to call. The connect route normalises what
* the user typed, but a member can PATCH `shop_domain` straight into the row
* through PostgREST, so the database value is not trusted to still be a
* myshopify.com host. Anything else is refused with a clear, non-retryable
* error instead of fetched.
*/
function shopOriginOf(shopDomain: string): string {
const normalized = normalizeShopDomain(shopDomain)
if (!normalized) {
throw new ShopifyApiError(
`Shopify shop domain is not a myshopify.com domain (${shopDomain}); reconnect the store`,
0,
INVALID_SHOP_DOMAIN_CODE,
)
}
return `https://${normalized}`
}
/**
* Map a failure from postJson to the error the retry loop should see. Guard
* refusals (private address, redirect) are terminal: retrying the same URL
* cannot succeed and must not spend the backoff budget.
*/
function asTerminalGuardError(err: unknown): ShopifyApiError | null {
if (err instanceof ShopifyApiError) return err
if (isUnsafeUrlError(err)) {
return new ShopifyApiError(
`Shopify host refused by outbound URL guard: ${err.detail}`,
0,
UNSAFE_SHOP_URL_CODE,
)
}
return null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function postJson(url: string, body: unknown, headers: Record<string, string>) {
return fetch(url, {
// safeFetch: public address only (checked now, not at connect time), no
// redirects. A 3xx from the host is a failure, never a hop.
return safeFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
body: JSON.stringify(body),
@@ -112,12 +164,14 @@ async function postJson(url: string, body: unknown, headers: Record<string, stri
* the normal backoff schedule.
*/
export async function exchangeAccessToken(creds: ShopifyCredentials): Promise<string> {
// Throws (non-retryable) when the stored domain is not a myshopify.com host.
const url = `${shopOriginOf(creds.shopDomain)}/admin/oauth/access_token`
let lastError: unknown
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
let response: Response
try {
response = await postJson(
`https://${creds.shopDomain}/admin/oauth/access_token`,
url,
{
client_id: creds.clientId,
client_secret: creds.clientSecret,
@@ -126,6 +180,8 @@ export async function exchangeAccessToken(creds: ShopifyCredentials): Promise<st
{},
)
} catch (err) {
const terminal = asTerminalGuardError(err)
if (terminal) throw terminal
lastError = new ShopifyApiError(
`Shopify token exchange failed: ${err instanceof Error ? err.message : String(err)}`,
0,
@@ -203,7 +259,8 @@ export async function shopifyGraphQL<T>(
query: string,
variables: Record<string, unknown> = {},
): Promise<T> {
const url = `https://${session.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`
// Throws (non-retryable) when the stored domain is not a myshopify.com host.
const url = `${shopOriginOf(session.shopDomain)}/admin/api/${SHOPIFY_API_VERSION}/graphql.json`
let lastError: unknown
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
let response: Response
@@ -212,6 +269,8 @@ export async function shopifyGraphQL<T>(
'X-Shopify-Access-Token': session.accessToken,
})
} catch (err) {
const terminal = asTerminalGuardError(err)
if (terminal) throw terminal
lastError = new ShopifyApiError(
`Shopify request failed: ${err instanceof Error ? err.message : String(err)}`,
0,
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// after() must be observable: the callback hands it the eager refresh
// promise so the serverless function stays alive past the response.
@@ -132,6 +132,12 @@ function callbackRequest(params: string) {
describe('skatteverket OAuth callback', () => {
beforeEach(() => {
vi.clearAllMocks()
// Hosted shape: the OAuth redirect_uri is pinned to a different host than
// the app (app.gnubok.se vs app.accounted.se), so the callback cannot
// expect the app's session cookies. The same-origin tests below override.
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example')
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://oauth.example')
vi.spyOn(console, 'warn').mockImplementation(() => {})
mockCreateServiceClient.mockReturnValue(makeServiceSupabase() as any)
// No session cookies by default: the callback is served on the pinned
// OAuth host (app.gnubok.se), where the user-facing app's session does
@@ -146,6 +152,11 @@ describe('skatteverket OAuth callback', () => {
})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('responds with the success page WITHOUT awaiting the post-connect refresh', async () => {
// A refresh that never settles: if the handler regressed to awaiting it,
// this test would hang into the vitest timeout instead of passing.
@@ -176,9 +187,10 @@ describe('skatteverket OAuth callback', () => {
expect.objectContaining({ access_token: 'at' }),
'company-1',
)
// The stored oauth_user_id resolved the user; the cookie-bound client
// must never be needed on the modern path.
expect(mockCreateClient).not.toHaveBeenCalled()
// The stored oauth_user_id resolved the user. The cookie session is
// consulted only to catch a DIFFERENT signed-in user; on the pinned OAuth
// host it is empty, and that must not block the flow.
expect(mockCreateClient).toHaveBeenCalledTimes(1)
// The state lookup must be recency-bounded: an old state row (leaked or
// phished authorize URL) must not stay completable indefinitely.
const service = mockCreateServiceClient.mock.results[0]!.value
@@ -334,6 +346,83 @@ describe('skatteverket OAuth callback', () => {
expect(mockExchange).not.toHaveBeenCalled()
expect(mockStoreTokens).not.toHaveBeenCalled()
})
// The state row names who started the flow; the tokens are stored for that
// user with the service client. The browser finishing the flow must be that
// user whenever a session can be read at all.
describe('binding the completion to the initiator', () => {
it('finalises when the signed-in user is the one who started the flow', async () => {
mockCreateClient.mockResolvedValue(makeCookieClient('user-1') as any)
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 } as any)
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
expect(await response.text()).toContain('skatteverket-oauth-success')
expect(mockExchange).toHaveBeenCalledTimes(1)
expect(mockStoreTokens).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({ access_token: 'at' }),
'company-1',
)
})
it('refuses a completion by a different signed-in user, on any host', async () => {
// The victim (user-2) was lured into approving user-1's consent.
mockCreateClient.mockResolvedValue(makeCookieClient('user-2') as any)
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('skatteverket-oauth-error')
expect(html).toContain('annat användarkonto')
// Refused before the exchange: the one-shot code is not burned and no
// token is written under the initiator's id.
expect(mockExchange).not.toHaveBeenCalled()
expect(mockStoreTokens).not.toHaveBeenCalled()
expect(mockRefresh).not.toHaveBeenCalled()
})
it('sends a session-less completion to login when the OAuth host IS the app host', async () => {
// Self-hosted shape (or the hosted pin removed): the initiator's cookies
// do arrive here, so an empty session means someone else is finishing it.
vi.stubEnv('NEXT_PUBLIC_SKV_OAUTH_BASE_URL', 'https://app.example')
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(307)
const location = new URL(response.headers.get('location') as string)
expect(location.origin).toBe('https://app.example')
expect(location.pathname).toBe('/login')
// The state row is untouched until the exchange, so signing in and
// re-running this exact callback completes the flow for its initiator.
expect(location.searchParams.get('next')).toBe(
`/api/extensions/ext/skatteverket/callback?code=abc&state=${STATE}`,
)
expect(mockExchange).not.toHaveBeenCalled()
expect(mockStoreTokens).not.toHaveBeenCalled()
})
it('keeps tolerating a missing session on the pinned OAuth host (no cookies can arrive)', async () => {
mockRefresh.mockResolvedValue({ synced: true, reconciled: 0 } as any)
const response = await callbackRoute().handler(
callbackRequest(`code=abc&state=${STATE}`),
)
expect(response.status).toBe(200)
expect(await response.text()).toContain('skatteverket-oauth-success')
expect(mockExchange).toHaveBeenCalledTimes(1)
})
})
})
// Connector branch: a self-hosted instance's SKV consent, started through the
+41 -2
View File
@@ -14,6 +14,10 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
import { skatteverketConnectorMode, startConnectorAuthorization } from './lib/connector-mode'
import { isConnectorState, verifyConnectorState } from '@/lib/connect/hosted/state'
import {
requireFlowInitiator,
FLOW_INITIATOR_MISMATCH_MESSAGE,
} from '@/lib/auth/oauth-flow-binding'
import { storeTokens, getTokens, deleteTokens, getTokenHealth } from './lib/token-store'
import { skvRequest, skvRequestWithAuth, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
import { writeSkatteverketAudit } from './lib/audit'
@@ -558,8 +562,43 @@ export const skatteverketExtension: Extension = {
// Flows that started before oauth_user_id shipped ran on the same
// domain as the app and still carry session cookies; fall back to
// those so in-flight connects survive the deploy boundary.
let userId = await readSetting('oauth_user_id')
if (!userId) {
const storedUserId = await readSetting('oauth_user_id')
let userId = storedUserId
if (storedUserId) {
// The state row names the user who started the flow; the tokens
// below are stored for that user with the service client. Bind the
// completion to that user's own session so a victim lured into
// approving a Skatteverket consent someone else started cannot have
// their BankID-authorised access stored under that someone.
//
// Hosted, this callback is served on the pinned OAuth host
// (getSkvOauthBaseUrl, app.gnubok.se) where the app's session
// cookies never arrive: a missing session proves nothing there and
// the single-use state + membership check stay the guard. Where the
// OAuth host IS the app host (self-hosted, or the pin removed) the
// initiator's cookies do arrive, so no session means the initiator
// is not the one finishing the flow. A session for a DIFFERENT user
// is refused on every host.
const initiator = await requireFlowInitiator(request, storedUserId, {
flow: 'skatteverket.callback',
})
if (!initiator.ok) {
const sessionExpected =
new URL(getSkvOauthBaseUrl()).origin === new URL(appUrl).origin
if (initiator.reason === 'mismatch') {
return respondWithError(
FLOW_INITIATOR_MISMATCH_MESSAGE,
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent(FLOW_INITIATOR_MISMATCH_MESSAGE)}`,
)
}
if (sessionExpected) {
// The state row is untouched until the exchange, so signing in
// and re-running this callback (the helper's /login?next=...)
// completes the flow for its initiator.
return initiator.response
}
}
} else {
const cookieClient = await createClient()
const { data: { user } } = await cookieClient.auth.getUser()
userId = user?.id ?? null
@@ -15,6 +15,21 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
// The confirmation mail (generateLink + platform email service + brand
// lookup) is covered in bankid-confirmation-mail.test.ts; here it is a seam
// so the route tests can assert WHEN it is sent and to WHOM.
vi.mock('../lib/bankid-confirmation-mail', () => ({
sendBankIdSignupConfirmation: vi.fn(),
}))
// The invite-only brand gate reads the brands table for any non-empty host;
// it has its own tests. Open here so a forwarded host can be asserted on the
// mail without a database.
vi.mock('@/lib/auth/brand-signup-gate', () => ({
evaluateBrandSignupGate: vi.fn().mockResolvedValue({ allowed: true }),
readInviteTokenFromCookieHeader: vi.fn().mockReturnValue(null),
}))
import {
cancelBankIdSession,
collectBankIdResult,
@@ -23,6 +38,7 @@ import {
fetchEnrichmentData,
startBankIdAuth,
} from '../lib/bankid-client'
import { sendBankIdSignupConfirmation } from '../lib/bankid-confirmation-mail'
import { createServiceClient } from '@/lib/supabase/server'
import { ticExtension } from '../index'
import {
@@ -141,9 +157,13 @@ function mockServiceClient(
return { admin, client }
}
/** A verified identity row, as every pre-2026-09 row is after the backfill. */
const VERIFIED_AT = '2026-01-01T00:00:00Z'
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('BANKID_ENCRYPTION_KEY', TEST_KEY)
vi.mocked(sendBankIdSignupConfirmation).mockResolvedValue({ ok: true })
})
afterEach(() => {
@@ -213,37 +233,71 @@ describe('POST /bankid/complete', () => {
})
})
describe('signup mode: happy path', () => {
it('creates a new user, marks bankid_linked, and returns the magic link tokenHash', async () => {
describe('signup mode: happy path (account pre-hijacking fix, audit 2026-09)', () => {
it('creates an UNCONFIRMED user with a PENDING identity, mails the typed address, and returns no session', async () => {
// The address came from the request body and nothing proved it belongs
// to the BankID holder. The old flow confirmed it, granted the MFA
// exemption and handed the browser a magic link; the real owner of the
// address could later adopt the account while the BankID kept a login.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
const { admin, client } = mockServiceClient([
{ data: null }, // pnr lookup → not linked
{ error: null }, // bankid_identities insert OK
])
const insertSpy = vi.fn().mockResolvedValue({ error: null })
const origFrom = client.from as unknown as ReturnType<typeof vi.fn>
const queuedFrom = origFrom.getMockImplementation() as (table: string) => unknown
let identityCalls = 0
origFrom.mockImplementation((table: string) => {
if (table === 'bankid_identities' && ++identityCalls === 2) {
return { insert: insertSpy }
}
return queuedFrom(table)
})
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'fresh@example.com' },
headers: { ...(await flowCookie('signup')), 'x-forwarded-host': 'app.gnubok.se', 'x-forwarded-proto': 'https' },
body: { email: 'Fresh@Example.com ' },
})
const response = await findCompleteHandler()(req)
const raw = await response.clone().text()
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; type?: string; isNewUser?: boolean }
}>(await findCompleteHandler()(req))
data?: { status?: string; email?: string; tokenHash?: string }
}>(response)
expect(status).toBe(200)
expect(body.data?.tokenHash).toBe('magic-token-hash')
expect(body.data?.type).toBe('magiclink')
expect(body.data?.isNewUser).toBe(true)
// Same shape as POST /api/auth/signup: the register page shows its
// "check your inbox" screen. Nothing here signs anyone in.
expect(body.data).toEqual({ status: 'confirmation_sent', email: 'fresh@example.com' })
expect(raw).not.toContain('magic-token-hash')
expect(raw).not.toContain('tokenHash')
// The route itself mints no link; only the mail helper does, server-side.
expect(admin.generateLink).not.toHaveBeenCalled()
expect(admin.createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'fresh@example.com', email_confirm: true })
expect.objectContaining({ email: 'fresh@example.com', email_confirm: false })
)
// Pending, not linked: bankid_linked (the MFA exemption) is granted by
// /auth/callback once the mail is clicked.
expect(admin.updateUserById).toHaveBeenCalledWith(
'new-user-uuid',
expect.objectContaining({
app_metadata: { bankid_linked: true, has_password: false },
app_metadata: { bankid_pending: true, has_password: false },
})
)
expect(insertSpy).toHaveBeenCalledWith(
expect.objectContaining({ user_id: 'new-user-uuid', email_verified_at: null })
)
expect(insertSpy.mock.calls[0][0]).not.toHaveProperty('bankid_linked')
expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1)
expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith({
supabase: client,
email: 'fresh@example.com',
host: 'app.gnubok.se',
proto: 'https',
})
expect(admin.deleteUser).not.toHaveBeenCalled()
})
})
@@ -251,7 +305,7 @@ describe('POST /bankid/complete', () => {
it('returns 409 already_linked before email lookup', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'some-other-user' } }, // pnr lookup → LINKED
{ data: { user_id: 'some-other-user', email_verified_at: VERIFIED_AT } }, // pnr lookup → LINKED
])
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
@@ -300,28 +354,32 @@ describe('POST /bankid/complete', () => {
expect(admin.generateLink).not.toHaveBeenCalled()
})
it('deletes the created user when generateLink fails', async () => {
it('deletes the created user when the confirmation mail cannot be sent', async () => {
// An account whose address will never receive its confirmation link is
// unusable AND blocks the address; roll it back so a retry starts clean.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: null }, // pnr lookup → not linked
{ error: null }, // identity insert OK
])
admin.generateLink.mockResolvedValueOnce({
data: null,
error: { message: 'link boom' },
} as never)
vi.mocked(sendBankIdSignupConfirmation).mockResolvedValueOnce({
ok: false,
step: 'send',
message: 'Email service not configured',
})
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{ error?: string }>(
const { status, body } = await parseJsonResponse<{ error?: string; message?: string }>(
await findCompleteHandler()(req)
)
expect(status).toBe(500)
expect(body.error).toBe('internal_error')
expect(body.message).toBe('Kunde inte skicka bekräftelsemailet. Försök igen.')
expect(admin.deleteUser).toHaveBeenCalledWith('new-user-uuid')
})
@@ -388,6 +446,323 @@ describe('POST /bankid/complete', () => {
expect(body.error).toBe('no_account')
expect(admin.generateLink).not.toHaveBeenCalled()
})
it('answers 503 (not no_account) when the identity lookup itself fails', async () => {
// A schema behind the code or a lost connection must not send every
// returning BankID user to signup, nor create anything.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: null, error: { code: '42703', message: 'column "email_verified_at" does not exist' } },
])
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(503)
expect(body.error).toBe('service_unavailable')
expect(admin.generateLink).not.toHaveBeenCalled()
expect(admin.createUser).not.toHaveBeenCalled()
})
it('treats PGRST116 (no row) as not linked', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
mockServiceClient([
{ data: null, error: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' } },
])
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(404)
expect(body.error).toBe('no_account')
})
it('signs a VERIFIED identity in with a magic link, as before', async () => {
// Every identity that existed before the pending column was added is
// backfilled as verified; their login must be byte-identical.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } },
])
const req = createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; type?: string; isNewUser?: boolean }
}>(await findCompleteHandler()(req))
expect(status).toBe(200)
expect(body.data).toEqual({ tokenHash: 'magic-token-hash', type: 'magiclink', isNewUser: false })
expect(admin.generateLink).toHaveBeenCalledWith({
type: 'magiclink',
email: 'existing@example.com',
})
expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled()
})
})
describe('pending identities (address never proven, audit 2026-09)', () => {
/** The auth user a BankID signup leaves behind until the mail is clicked. */
const pendingShell = {
id: 'pending-user',
email: 'typed@example.com',
email_confirmed_at: undefined,
identities: [{ provider: 'email' }],
app_metadata: { bankid_pending: true, has_password: false },
}
function clearedFlow(response: Response): boolean {
return response.headers
.getSetCookie()
.some((c) => c.startsWith(`${BANKID_FLOW_COOKIE}=`) && /Max-Age=0/i.test(c))
}
it('login: refuses a pending identity, re-sends the confirmation mail, and mints nothing', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'pending-user', email_verified_at: null } },
])
admin.getUserById.mockResolvedValue({ data: { user: pendingShell }, error: null } as never)
const response = await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: { ...(await flowCookie('login')), 'x-forwarded-host': 'app.gnubok.se' },
})
)
const raw = await response.clone().text()
const { status, body } = await parseJsonResponse<{ error?: string; message?: string }>(response)
expect(status).toBe(403)
expect(body.error).toBe('email_unconfirmed')
expect(body.message).toMatch(/^Bekräfta din e-postadress först/)
expect(raw).not.toContain('tokenHash')
expect(admin.generateLink).not.toHaveBeenCalled()
expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith(
expect.objectContaining({ email: 'typed@example.com', host: 'app.gnubok.se' })
)
// The flag was already set, so nothing to heal; the row stays for the click.
expect(admin.updateUserById).not.toHaveBeenCalled()
expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual(['bankid_identities'])
// Terminal for this identification: the flow is spent.
expect(clearedFlow(response)).toBe(true)
})
it('login: heals a missing bankid_pending flag before re-sending (rows from the old flow)', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'legacy-user', email_verified_at: null } },
])
admin.getUserById.mockResolvedValue({
data: {
user: {
...pendingShell,
id: 'legacy-user',
email_confirmed_at: '2026-09-02T08:00:00Z',
app_metadata: { bankid_linked: true, has_password: false },
},
},
error: null,
} as never)
const { status } = await parseJsonResponse(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(403)
// /auth/callback only promotes when the flag is present.
expect(admin.updateUserById).toHaveBeenCalledWith('legacy-user', {
app_metadata: { bankid_linked: true, has_password: false, bankid_pending: true },
})
expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1)
})
it('login: revokes the pending link and answers no_account once the address owner adopted the account', async () => {
// The victim signed in with Google (or set a password): the account is
// theirs. The BankID holder who typed their address gets no login into
// it, now or after any later confirmation click.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'victim-user', email_verified_at: null } }, // pnr lookup → pending
{ error: null }, // bankid_identities delete OK
])
admin.getUserById.mockResolvedValue({
data: {
user: {
...pendingShell,
id: 'victim-user',
email: 'victim@example.com',
identities: [{ provider: 'email' }, { provider: 'google' }],
},
},
error: null,
} as never)
const { status, body } = await parseJsonResponse<{ error?: string; givenName?: string }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('login'),
})
)
)
expect(status).toBe(404)
expect(body.error).toBe('no_account')
expect(body.givenName).toBe('Anna')
expect(admin.generateLink).not.toHaveBeenCalled()
expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled()
// Row deleted, flag dropped, no MFA exemption granted.
expect(vi.mocked(client.from).mock.calls.map((c) => c[0])).toEqual([
'bankid_identities',
'bankid_identities',
])
expect(admin.updateUserById).toHaveBeenCalledWith('victim-user', {
app_metadata: { bankid_pending: null, has_password: false },
})
expect(admin.deleteUser).not.toHaveBeenCalled()
})
it('login: a pending identity whose user set a password is treated as adopted', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'victim-user', email_verified_at: null } },
{ error: null },
])
admin.getUserById.mockResolvedValue({
data: {
user: { ...pendingShell, id: 'victim-user', app_metadata: { bankid_pending: true, has_password: true } },
},
error: null,
} as never)
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(404)
expect(body.error).toBe('no_account')
expect(sendBankIdSignupConfirmation).not.toHaveBeenCalled()
})
it('signup: replaces a stale unadopted pending account from the same BankID (typo, lost mail)', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'stale-user', email_verified_at: null } }, // pnr lookup → pending
{ error: null }, // new bankid_identities insert OK
])
admin.getUserById.mockResolvedValue({
data: { user: { ...pendingShell, id: 'stale-user', email: 'typo@exmaple.com' } },
error: null,
} as never)
const { status, body } = await parseJsonResponse<{ data?: { status?: string } }>(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'correct@example.com' },
})
)
)
expect(status).toBe(200)
expect(body.data?.status).toBe('confirmation_sent')
// The unconfirmed shell (cascades its identity row) goes; a fresh one is made.
expect(admin.deleteUser).toHaveBeenCalledTimes(1)
expect(admin.deleteUser).toHaveBeenCalledWith('stale-user')
expect(admin.createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'correct@example.com', email_confirm: false })
)
expect(sendBankIdSignupConfirmation).toHaveBeenCalledWith(
expect.objectContaining({ email: 'correct@example.com' })
)
})
it('signup: never deletes an adopted account; only the pending link is removed before the new signup', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin, client } = mockServiceClient([
{ data: { user_id: 'victim-user', email_verified_at: null } }, // pnr lookup → pending
{ error: null }, // pending row delete OK
{ error: null }, // new bankid_identities insert OK
])
admin.getUserById.mockResolvedValue({
data: {
user: {
...pendingShell,
id: 'victim-user',
email_confirmed_at: '2026-09-01T00:00:00Z',
identities: [{ provider: 'email' }, { provider: 'google' }],
},
},
error: null,
} as never)
const { status } = await parseJsonResponse(
await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'mine@example.com' },
})
)
)
expect(status).toBe(200)
expect(admin.deleteUser).not.toHaveBeenCalled()
expect(vi.mocked(client.from).mock.calls.filter((c) => c[0] === 'bankid_identities')).toHaveLength(3)
expect(admin.updateUserById).toHaveBeenCalledWith('victim-user', {
app_metadata: { bankid_pending: null, has_password: false },
})
expect(admin.createUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'mine@example.com' })
)
})
it('signup: fails closed (500, flow kept) when the stale pending link cannot be cleared', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
{ data: { user_id: 'stale-user', email_verified_at: null } },
])
admin.getUserById.mockResolvedValue({ data: { user: { ...pendingShell, id: 'stale-user' } }, error: null } as never)
admin.deleteUser.mockResolvedValueOnce({ data: null, error: { message: 'boom' } } as never)
const response = await findCompleteHandler()(
createMockRequest('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: await flowCookie('signup'),
body: { email: 'correct@example.com' },
})
)
expect(response.status).toBe(500)
expect(admin.createUser).not.toHaveBeenCalled()
expect(clearedFlow(response)).toBe(false)
})
})
describe('enrichment: SPAR + CompanyRoles', () => {
@@ -457,11 +832,11 @@ describe('POST /bankid/complete', () => {
body: { email: 'fresh@example.com' },
})
const { status, body } = await parseJsonResponse<{
data?: { tokenHash?: string; isNewUser?: boolean }
data?: { status?: string }
}>(await findCompleteHandler()(req))
expect(status).toBe(200)
expect(body.data?.isNewUser).toBe(true)
expect(body.data?.status).toBe('confirmation_sent')
expect(vi.mocked(requestEnrichment)).toHaveBeenCalledWith(
'test-session',
['SPAR', 'CompanyRoles']
@@ -586,9 +961,9 @@ describe('POST /bankid/complete', () => {
expect(collectBankIdResult).not.toHaveBeenCalled()
})
it('spends the flow on success, so a second tab cannot mint a rival magic link', async () => {
it('spends the flow on success, so a second tab cannot send a rival confirmation mail', async () => {
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient([
mockServiceClient([
{ data: null }, // pnr lookup → not linked
{ error: null }, // bankid_identities insert OK
])
@@ -602,9 +977,9 @@ describe('POST /bankid/complete', () => {
)
expect(response.status).toBe(200)
expect(admin.generateLink).toHaveBeenCalled()
expect(sendBankIdSignupConfirmation).toHaveBeenCalledTimes(1)
// Without this, two tabs that both saw 'complete' would each mint a
// magic link and the second would invalidate the first.
// link and the second would invalidate the first.
expect(clearedFlow(response)).toBe(true)
})
@@ -615,7 +990,7 @@ describe('POST /bankid/complete', () => {
// 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
[{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } }], // pnr is linked
{ error: { code: '23505', message: 'duplicate key' } },
)
@@ -662,7 +1037,7 @@ describe('POST /bankid/complete', () => {
// authenticate again, so an unreachable table must not be waved through.
vi.mocked(collectBankIdResult).mockResolvedValue(makeSession())
const { admin } = mockServiceClient(
[{ data: { user_id: 'existing-user' } }],
[{ data: { user_id: 'existing-user', email_verified_at: VERIFIED_AT } }],
{ error: { code: '42P01', message: 'relation does not exist' } },
)
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
const sendEmailMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ sendEmail: sendEmailMock, isConfigured: () => true }),
}))
const resolveBrandByHostMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandByHost: resolveBrandByHostMock,
// Imported by lib/email/brand-sender (not called on this path).
resolveBrandForCompany: vi.fn(),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.gnubok.se' }),
}))
import {
buildConfirmationUrl,
sendBankIdSignupConfirmation,
} from '../lib/bankid-confirmation-mail'
function serviceClient(generateLinkResult: unknown) {
const generateLink = vi.fn().mockResolvedValue(generateLinkResult)
return {
generateLink,
supabase: { auth: { admin: { generateLink } } } as unknown as SupabaseClient,
}
}
const LINK_OK = { data: { properties: { hashed_token: 'hashed-123' } }, error: null }
beforeEach(() => {
vi.clearAllMocks()
resolveBrandByHostMock.mockResolvedValue(null)
sendEmailMock.mockResolvedValue({ success: true, messageId: 'm-1' })
})
describe('buildConfirmationUrl', () => {
it('lands on the originating host with the token_hash + magiclink verify pattern', () => {
expect(buildConfirmationUrl('app.siffra.se', 'https', 'tok')).toBe(
'https://app.siffra.se/auth/callback?token_hash=tok&type=magiclink',
)
})
it('defaults to https when the proxy did not forward a protocol', () => {
expect(buildConfirmationUrl('app.siffra.se', null, 'tok')).toMatch(/^https:\/\/app\.siffra\.se\//)
})
it('falls back to the canonical app URL without a host', () => {
expect(buildConfirmationUrl('', undefined, 'tok')).toBe(
'https://app.gnubok.se/auth/callback?token_hash=tok&type=magiclink',
)
})
})
describe('sendBankIdSignupConfirmation', () => {
it('mints a magic link server-side and mails it to the typed address, never returning the token', async () => {
const { supabase, generateLink } = serviceClient(LINK_OK)
const result = await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: 'app.gnubok.se',
proto: 'https',
})
expect(result).toEqual({ ok: true })
expect(generateLink).toHaveBeenCalledWith({ type: 'magiclink', email: 'fresh@example.com' })
expect(sendEmailMock).toHaveBeenCalledTimes(1)
const mail = sendEmailMock.mock.calls[0][0]
expect(mail.to).toBe('fresh@example.com')
expect(mail.subject).toBe('Bekräfta din e-postadress')
expect(mail.text).toContain(
'https://app.gnubok.se/auth/callback?token_hash=hashed-123&type=magiclink',
)
expect(mail.text).toContain('BankID')
// Platform sender: no brand on the canonical host.
expect(mail.fromName).toBeUndefined()
expect(mail.fromAddress).toBeUndefined()
})
it('sends in the brand of the requesting host', async () => {
resolveBrandByHostMock.mockResolvedValue({
appName: 'Siffra',
domain: 'app.siffra.se',
supportEmail: 'support@siffra.se',
authEmailFrom: 'noreply@post.siffra.se',
senderDomainStatus: 'verified',
})
const { supabase } = serviceClient(LINK_OK)
await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: 'app.siffra.se',
proto: 'https',
})
expect(resolveBrandByHostMock).toHaveBeenCalledWith('app.siffra.se')
const mail = sendEmailMock.mock.calls[0][0]
expect(mail.fromName).toBe('Siffra')
expect(mail.fromAddress).toBe('noreply@post.siffra.se')
expect(mail.replyTo).toBe('support@siffra.se')
expect(mail.text).toContain('https://app.siffra.se/auth/callback?token_hash=hashed-123')
expect(mail.html).not.toMatch(/accounted/i)
})
it('reports a generateLink failure without sending anything', async () => {
const { supabase } = serviceClient({ data: null, error: { message: 'link boom', code: 'x' } })
const result = await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: '',
})
expect(result).toEqual({ ok: false, step: 'generate_link', message: 'link boom' })
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('reports a send failure so the caller can roll the signup back', async () => {
sendEmailMock.mockResolvedValue({ success: false, error: 'Email service not configured' })
const { supabase } = serviceClient(LINK_OK)
const result = await sendBankIdSignupConfirmation({
supabase,
email: 'fresh@example.com',
host: '',
})
expect(result).toEqual({ ok: false, step: 'send', message: 'Email service not configured' })
})
})
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
hasForeignCredential,
isUnadoptedPendingAccount,
revokePendingIdentity,
} from '../lib/bankid-pending'
function identity(provider: string) {
return { provider } as unknown as NonNullable<
Parameters<typeof hasForeignCredential>[0]['identities']
>[number]
}
describe('hasForeignCredential', () => {
it('is false for the shell a BankID signup makes (email identity, no password)', () => {
expect(
hasForeignCredential({
identities: [identity('email')],
app_metadata: { bankid_pending: true, has_password: false },
email_confirmed_at: undefined,
}),
).toBe(false)
})
it('is false when identities are absent altogether', () => {
expect(hasForeignCredential({ app_metadata: {} })).toBe(false)
})
it('is true once a Google identity is attached', () => {
expect(
hasForeignCredential({
identities: [identity('email'), identity('google')],
app_metadata: { bankid_pending: true },
}),
).toBe(true)
})
it('is true once the user has set a password themselves', () => {
expect(
hasForeignCredential({
identities: [identity('email')],
app_metadata: { bankid_pending: true, has_password: true },
}),
).toBe(true)
})
})
describe('isUnadoptedPendingAccount', () => {
it('is true only while the address is unconfirmed and no foreign credential exists', () => {
expect(
isUnadoptedPendingAccount({
identities: [identity('email')],
app_metadata: { bankid_pending: true, has_password: false },
email_confirmed_at: undefined,
}),
).toBe(true)
})
it('is false once the address was confirmed by any means', () => {
// A confirmed account is never deleted on re-signup, even without a
// foreign credential: the old flow admin-confirmed addresses it never
// proved, and those accounts may have data.
expect(
isUnadoptedPendingAccount({
identities: [identity('email')],
app_metadata: { bankid_linked: true },
email_confirmed_at: '2026-08-01T00:00:00Z',
}),
).toBe(false)
})
it('is false when a foreign credential exists even if unconfirmed', () => {
expect(
isUnadoptedPendingAccount({
identities: [identity('email')],
app_metadata: { has_password: true },
email_confirmed_at: undefined,
}),
).toBe(false)
})
})
describe('revokePendingIdentity', () => {
const updateUserById = vi.fn()
const calls: Array<{ method: string; args: unknown[] }> = []
let deleteResult: { error: { code?: string; message: string } | null }
function chain(): unknown {
return new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(deleteResult)
}
return (...args: unknown[]) => {
calls.push({ method: String(prop), args })
return chain()
}
},
},
)
}
const supabase = {
from: vi.fn(() => chain()),
auth: { admin: { updateUserById } },
} as unknown as SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
calls.length = 0
deleteResult = { error: null }
updateUserById.mockResolvedValue({ data: {}, error: null })
})
it('deletes only unverified rows for the user and drops bankid_pending, keeping the rest of app_metadata', async () => {
const ok = await revokePendingIdentity(supabase, 'user-1', {
bankid_pending: true,
has_password: true,
provider: 'email',
})
expect(ok).toBe(true)
expect(supabase.from).toHaveBeenCalledWith('bankid_identities')
expect(calls.map((c) => c.method)).toEqual(['delete', 'eq', 'is'])
expect(calls[1].args).toEqual(['user_id', 'user-1'])
expect(calls[2].args).toEqual(['email_verified_at', null])
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { bankid_pending: null, has_password: true, provider: 'email' },
})
// Never grants the MFA exemption on the way out.
const written = updateUserById.mock.calls[0][1].app_metadata as Record<string, unknown>
expect(written.bankid_linked).toBeUndefined()
})
it('returns false and leaves app_metadata alone when the delete fails', async () => {
deleteResult = { error: { code: 'XX000', message: 'boom' } }
const ok = await revokePendingIdentity(supabase, 'user-1', { bankid_pending: true })
expect(ok).toBe(false)
expect(updateUserById).not.toHaveBeenCalled()
})
it('tolerates missing prior metadata', async () => {
const ok = await revokePendingIdentity(supabase, 'user-1', undefined)
expect(ok).toBe(true)
expect(updateUserById).toHaveBeenCalledWith('user-1', {
app_metadata: { bankid_pending: null },
})
})
})
+198 -43
View File
@@ -35,6 +35,12 @@ import {
setBankIdFlowCookies,
} from './lib/bankid-flow-cookie'
import { lookupCompanyByOrgNumber, registrationDateToMs } from './lib/lookup'
import {
hasForeignCredential,
isUnadoptedPendingAccount,
revokePendingIdentity,
} from './lib/bankid-pending'
import { sendBankIdSignupConfirmation } from './lib/bankid-confirmation-mail'
import { hashPersonalNumber, encryptPersonalNumberForStorage } from '@/lib/auth/bankid'
import {
evaluateBrandSignupGate,
@@ -43,11 +49,109 @@ import {
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { SupabaseClient, User } from '@supabase/supabase-js'
import crypto from 'crypto'
const log = createLogger('tic/bankid')
const SIGNUP_FAILED = { error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' }
/** Host the browser is on, as the proxy forwarded it. '' when unknown. */
function forwardedHost(request: Request): string {
return request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? ''
}
/**
* Login attempt by a BankID identity whose address was never proven
* (`email_verified_at IS NULL`, see lib/bankid-pending.ts). Never mints a
* session. Two outcomes:
*
* - The account has since been adopted through another credential (the real
* owner of the address set a password or signed in with Google): the
* pending link is revoked so it can never be promoted, and the BankID
* holder is told there is no account, which for them is now true.
* - Otherwise the account is still the unconfirmed shell the signup made:
* the confirmation mail is re-sent (best effort; every attempt costs a
* BankID identification, which bounds the volume) and the caller is asked
* to confirm first. The message surfaces in the login page, so Swedish.
*/
async function refusePendingLogin(
supabase: SupabaseClient,
userId: string,
user: User | null | undefined,
names: { givenName?: string; surname?: string },
request: Request,
): Promise<NextResponse> {
if (!user || hasForeignCredential(user)) {
if (user) {
log.warn('pending bankid identity revoked at login: account adopted by another credential', {
userId,
})
}
await revokePendingIdentity(supabase, userId, user?.app_metadata)
return NextResponse.json({ error: 'no_account', ...names }, { status: 404 })
}
if (user.email) {
// /auth/callback only looks for a pending identity when the flag is set.
// Rows created by the old flow (before the column existed) have the flag
// missing; heal it here so the re-sent mail can actually promote them.
if (user.app_metadata?.bankid_pending !== true) {
await supabase.auth.admin.updateUserById(userId, {
app_metadata: { ...(user.app_metadata ?? {}), bankid_pending: true },
})
}
const sent = await sendBankIdSignupConfirmation({
supabase,
email: user.email,
host: forwardedHost(request),
proto: request.headers.get('x-forwarded-proto'),
})
if (!sent.ok) {
log.warn('could not re-send bankid confirmation mail', { userId, step: sent.step })
}
}
return NextResponse.json(
{
error: 'email_unconfirmed',
message:
'Bekräfta din e-postadress först. Vi har skickat ett nytt bekräftelsemail till adressen du angav när kontot skapades.',
},
{ status: 403 }
)
}
/**
* The same BankID signs up again while an earlier signup is still pending
* (typo'd address, lost mail). Clear the stale link so this attempt can start
* over with the address typed now. The stale account is deleted outright only
* while it is still the unconfirmed shell the signup made (bankid_identities
* cascades); if somebody else has adopted it in the meantime, only the BankID
* link is removed and their account is left alone. False = could not clear.
*/
async function clearStalePendingSignup(supabase: SupabaseClient, userId: string): Promise<boolean> {
const { data } = await supabase.auth.admin.getUserById(userId)
const user = data?.user
if (user && isUnadoptedPendingAccount(user)) {
const { error } = await supabase.auth.admin.deleteUser(userId)
if (error) {
log.error('could not delete stale pending bankid signup', { userId, message: error.message })
return false
}
log.info('stale pending bankid signup deleted for re-signup', { userId })
return true
}
if (user) {
log.warn('pending bankid identity revoked at re-signup: account adopted by another credential', {
userId,
})
}
return revokePendingIdentity(supabase, userId, user?.app_metadata)
}
/**
* Claim a BankID session, atomically and exactly once.
*
@@ -1033,13 +1137,31 @@ export const ticExtension: Extension = {
const pnrHash = hashPersonalNumber(personalNumber)
const supabase = createServiceClient()
// Look up existing BankID identity
const { data: existing } = await supabase
// Look up existing BankID identity. email_verified_at NULL means the
// address on the account was never proven (pending signup): such an
// identity signs nobody in and is not "linked" for signup purposes.
const { data: existing, error: lookupError } = await supabase
.from('bankid_identities')
.select('user_id')
.select('user_id, email_verified_at')
.eq('personal_number_hash', pnrHash)
.single()
// PGRST116 is .single() finding no row, which is the normal "not
// linked" answer. Anything else (schema behind the code, connection
// lost) must not read as "no account": that would send every
// returning BankID user to signup. Not settled, so the completed
// identification survives a retry.
if (lookupError && lookupError.code !== 'PGRST116') {
log.error('bankid_identities lookup failed', {
code: lookupError.code,
message: lookupError.message,
})
return NextResponse.json(
{ error: 'service_unavailable', message: 'Tillfälligt fel. Försök igen om en stund.' },
{ status: 503 }
)
}
if (mode === 'login') {
if (!existing) {
// Terminal for a login flow: the user is sent to signup, which
@@ -1051,8 +1173,23 @@ export const ticExtension: Extension = {
}, { status: 404 }))
}
// Returning user: generate magic link
// Returning user. Load the account before deciding anything: a
// pending identity is refused (never a magic link), see
// refusePendingLogin.
const { data: userData } = await supabase.auth.admin.getUserById(existing.user_id)
if (existing.email_verified_at === null) {
return settle(
await refusePendingLogin(
supabase,
existing.user_id,
userData?.user,
{ givenName, surname },
request,
)
)
}
if (!userData?.user?.email) {
// Data problem, not a transient one: an identity with no user
// email will never complete. settle() so it is not re-offered as
@@ -1105,7 +1242,7 @@ export const ticExtension: Extension = {
}
// mode === 'signup'
if (existing) {
if (existing && existing.email_verified_at !== null) {
// Terminal: this BankID already has an account, so the answer is
// to sign in, not to retry this session.
return settle(NextResponse.json(
@@ -1114,6 +1251,17 @@ export const ticExtension: Extension = {
))
}
if (existing) {
// Pending identity from an earlier signup by this same BankID.
// Not settled: the identification is still good, only the stale
// link is in the way. A failure here is transient by nature.
if (!await clearStalePendingSignup(supabase, existing.user_id)) {
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
}
const host = forwardedHost(request)
// Invite-only brand domain gate (founder decision 2026-08-27):
// same rule POST /api/auth/signup enforces on the email path. Runs
// AFTER the existing-identity check so a returning user's login is
@@ -1121,10 +1269,7 @@ export const ticExtension: Extension = {
// deliberately NOT settled, so the visitor keeps the completed
// BankID identification if the byrå allowlists them mid-flow.
const gateResult = await evaluateBrandSignupGate({
host:
request.headers.get('x-forwarded-host') ??
request.headers.get('host') ??
'',
host,
email: trimmedEmail!,
inviteToken: readInviteTokenFromCookieHeader(request.headers.get('cookie')),
})
@@ -1154,10 +1299,18 @@ export const ticExtension: Extension = {
// The profile mirror can lack the address while the auth row still
// holds it (anonymize_user_account scrubs profiles.email but keeps the
// auth tombstone), which used to fall through to a dead-end 500 here.
//
// email_confirm: false. The address came from the request body and
// nothing has proven it belongs to the person holding the BankID.
// Confirming it here let anyone open an account on a stranger's
// address and keep a BankID login into it after the stranger adopted
// it (account pre-hijacking, security audit 2026-09). The address is
// confirmed by the mail sent below, and only then does the identity
// count (see lib/bankid-pending.ts).
const randomPassword = crypto.randomBytes(32).toString('base64url')
const { data: newUser, error: createError } = await supabase.auth.admin.createUser({
email: trimmedEmail!,
email_confirm: true,
email_confirm: false,
password: randomPassword,
user_metadata: { full_name: name },
})
@@ -1192,10 +1345,7 @@ export const ticExtension: Extension = {
code: createError?.code,
message: createError?.message,
})
return NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
)
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
const userId = newUser.user.id
@@ -1216,24 +1366,22 @@ export const ticExtension: Extension = {
}
}
// Mark user as BankID-linked (skips TOTP MFA) and record that they
// do not have a password yet: the BankID signup gave them a random
// server-side password they will never see. This flag gates MFA
// enrollment (see lib/auth/has-password.ts).
// Mark the BankID link as PENDING, not linked: bankid_linked (which
// skips TOTP MFA, lib/auth/mfa.ts) is set by /auth/callback once the
// confirmation mail is clicked. has_password: false records that the
// BankID signup gave them a random server-side password they will
// never see; it gates MFA enrollment (see lib/auth/has-password.ts).
const { error: metaError } = await supabase.auth.admin.updateUserById(userId, {
app_metadata: { bankid_linked: true, has_password: false },
app_metadata: { bankid_pending: true, has_password: false },
})
if (metaError) {
log.error('signup app_metadata update failed', { message: metaError.message, code: metaError.code })
await rollbackSignup('app_metadata update')
return NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
)
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
// Store BankID identity
// Store BankID identity, unverified until the mail is clicked.
const { error: insertError } = await supabase
.from('bankid_identities')
.insert({
@@ -1242,20 +1390,18 @@ export const ticExtension: Extension = {
personal_number_enc: encryptPersonalNumberForStorage(personalNumber),
given_name: givenName,
surname,
email_verified_at: null,
})
if (insertError) {
log.error('insert bankid_identities failed', { message: insertError.message, code: insertError.code })
await rollbackSignup('bankid_identities insert')
return NextResponse.json(
{ error: 'internal_error', message: 'Kunde inte skapa kontot. Försök igen.' },
{ status: 500 }
)
return NextResponse.json(SIGNUP_FAILED, { status: 500 })
}
// Claim the session before minting. Placed after createUser so the
// Claim the session before mailing. Placed after createUser so the
// recoverable account_exists path above leaves the flow reusable,
// and before generateLink so two tabs cannot both mint.
// and before the mail so two tabs cannot both send one.
if (!await consumeBankIdSession(supabase, sessionId)) {
await rollbackSignup('session already consumed')
return settle(NextResponse.json(
@@ -1264,20 +1410,24 @@ export const ticExtension: Extension = {
))
}
// Generate magic link for session
const { data: link, error: linkError } = await supabase.auth.admin.generateLink({
type: 'magiclink',
// Mail the confirmation link to the typed address. The token never
// reaches the browser: whoever reads that inbox proves the address,
// and /auth/callback promotes the identity when they click.
const sent = await sendBankIdSignupConfirmation({
supabase,
email: trimmedEmail!,
host,
proto: request.headers.get('x-forwarded-proto'),
})
if (linkError || !link?.properties?.hashed_token) {
log.error('generateLink failed for signup', { message: linkError?.message, code: linkError?.code })
await rollbackSignup('generateLink')
if (!sent.ok) {
log.error('bankid signup confirmation mail failed', { step: sent.step, message: sent.message })
await rollbackSignup(`confirmation mail (${sent.step})`)
// 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.' },
{ error: 'internal_error', message: 'Kunde inte skicka bekräftelsemailet. Försök igen.' },
{ status: 500 }
))
}
@@ -1285,12 +1435,13 @@ export const ticExtension: Extension = {
// Enrichment (CompanyRoles): pre-fills /select-company picker.
await fetchAndStoreEnrichment(sessionId, userId, supabase)
// settle(): account created and magic link minted. The flow is spent.
// settle(): account created and the mail is out. The flow is spent.
// Same shape as POST /api/auth/signup, so the register page can show
// its existing "check your inbox" screen. No session, no token.
return settle(NextResponse.json({
data: {
tokenHash: link.properties.hashed_token,
type: 'magiclink',
isNewUser: true,
status: 'confirmation_sent',
email: trimmedEmail!,
},
}))
} catch (error) {
@@ -1444,7 +1595,10 @@ export const ticExtension: Extension = {
))
}
// Link BankID to current user
// Link BankID to current user. The caller is authenticated, so the
// address on the account is already theirs: verified from the start
// (the column has no default; NULL would make this a pending link
// that refuses to sign in).
const { error: insertError } = await supabase
.from('bankid_identities')
.insert({
@@ -1453,6 +1607,7 @@ export const ticExtension: Extension = {
personal_number_enc: encryptPersonalNumberForStorage(personalNumber),
given_name: givenName,
surname,
email_verified_at: new Date().toISOString(),
})
if (insertError) {
@@ -0,0 +1,99 @@
/**
* The confirmation mail a BankID signup sends to the typed address.
*
* The BankID signup used to mint a magic link and hand it straight back to the
* browser, which proved nothing about the address. Now the link travels only
* by mail, through the same token_hash + /auth/callback pattern the Supabase
* Send Email hook uses (app/api/auth/email-hook/route.ts), branded per the
* requesting host like every other auth mail. The browser never sees the
* token.
*
* `magiclink` rather than `signup` as the link type: GoTrue refuses a signup
* link for an already-confirmed address, and this mail is also re-sent when a
* pending identity tries to log in, which includes accounts created by the
* old flow (confirmed by admin, address never proven). Verifying a magic link
* confirms an unconfirmed address as a side effect, so both cases converge.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { getEmailService } from '@/lib/email/service'
import { buildAuthEmail } from '@/lib/email/auth-templates'
import { getSenderForBrand } from '@/lib/email/brand-sender'
import { getBranding } from '@/lib/branding/service'
import { resolveBrandByHost } from '@/lib/branding/resolve'
import { createLogger } from '@/lib/logger'
const log = createLogger('tic/bankid-confirmation-mail')
export interface SendBankIdConfirmationInput {
/** Service-role client (auth.admin.generateLink). */
supabase: SupabaseClient
/** Normalised (trimmed, lower-cased) recipient address. */
email: string
/** Forwarded host of the request, '' when unknown. */
host: string
/** Forwarded protocol of the request; defaults to https. */
proto?: string | null
}
export type SendBankIdConfirmationResult =
| { ok: true }
| { ok: false; step: 'generate_link' | 'send'; message?: string }
/**
* Confirmation links must land on the ORIGINATING host (the brand mail
* resolves its brand from it), mirroring POST /api/auth/signup. With no host
* (direct invocation, tests) the canonical app URL is used.
*/
export function buildConfirmationUrl(
host: string,
proto: string | null | undefined,
tokenHash: string,
): string {
const base = host ? `${proto || 'https'}://${host}` : getBranding().appUrl
const url = new URL('/auth/callback', base)
url.searchParams.set('token_hash', tokenHash)
url.searchParams.set('type', 'magiclink')
return url.toString()
}
export async function sendBankIdSignupConfirmation(
input: SendBankIdConfirmationInput,
): Promise<SendBankIdConfirmationResult> {
const { data: link, error: linkError } = await input.supabase.auth.admin.generateLink({
type: 'magiclink',
email: input.email,
})
if (linkError || !link?.properties?.hashed_token) {
log.error('generateLink failed for bankid confirmation mail', {
code: linkError?.code,
message: linkError?.message,
})
return { ok: false, step: 'generate_link', message: linkError?.message }
}
const brand = input.host ? await resolveBrandByHost(input.host) : null
const sender = getSenderForBrand(brand)
const appName = brand?.appName ?? getBranding().appName
const mail = buildAuthEmail({
actionType: 'bankid_signup',
appName,
actionUrl: buildConfirmationUrl(input.host, input.proto, link.properties.hashed_token),
})
const result = await getEmailService().sendEmail({
to: input.email,
subject: mail.subject,
html: mail.html,
text: mail.text,
fromName: sender.fromName ?? undefined,
fromAddress: sender.fromAddress ?? undefined,
replyTo: sender.replyTo ?? undefined,
})
if (!result.success) {
log.error('bankid confirmation mail send failed', new Error(result.error ?? 'unknown'))
return { ok: false, step: 'send', message: result.error }
}
return { ok: true }
}
@@ -0,0 +1,90 @@
/**
* Pending BankID identities (security audit 2026-09, account pre-hijacking).
*
* A BankID signup creates the auth user for whatever address the caller typed.
* Until that address is proven (the confirmation mail is clicked and
* /auth/callback promotes the row), the `bankid_identities` row carries
* `email_verified_at IS NULL` and the auth user carries
* `app_metadata.bankid_pending: true` instead of `bankid_linked: true`. A
* pending identity must never sign anyone in, and it must never be promoted
* once the account has been adopted through another credential (the real
* owner of the address set a password or signed in with Google).
*
* The promotion side lives in app/(auth)/auth/callback/route.ts (core, so it
* cannot import this module); `hasForeignCredential` there mirrors the one
* here on purpose. Keep the two in step.
*/
import type { SupabaseClient, User } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('tic/bankid-pending')
/** The subset of the auth user the adoption checks read. */
export type AdoptionUser = Pick<User, 'identities' | 'app_metadata' | 'email_confirmed_at'>
/**
* True when the account has a credential the BankID signup never created:
* a non-email identity (Google) or a password the user set themselves
* (`has_password: true`, written only by POST /api/account/password). Either
* one means somebody proved ownership of the address by other means, and the
* pending BankID identity must be revoked rather than promoted.
*/
export function hasForeignCredential(user: AdoptionUser): boolean {
const identities = user.identities ?? []
if (identities.some((identity) => identity.provider !== 'email')) return true
return user.app_metadata?.has_password === true
}
/**
* True when the pending account is still exactly what the BankID signup made:
* address unconfirmed, no foreign credential. Only such an account may be
* deleted outright when the same BankID signs up again (typo'd address, lost
* mail): nobody else has a stake in it yet.
*/
export function isUnadoptedPendingAccount(user: AdoptionUser): boolean {
return !user.email_confirmed_at && !hasForeignCredential(user)
}
/**
* Remove the pending BankID link from an account that somebody else now owns:
* delete the unverified identity row(s) and drop `bankid_pending` from
* app_metadata. `bankid_linked` is untouched (a pending signup never set it).
* Read-merge-write on app_metadata, like every other writer in this
* extension. Returns false when the row delete failed; the flag update is
* best-effort.
*/
export async function revokePendingIdentity(
supabase: SupabaseClient,
userId: string,
priorAppMetadata: Record<string, unknown> | undefined,
): Promise<boolean> {
const { error: deleteError } = await supabase
.from('bankid_identities')
.delete()
.eq('user_id', userId)
.is('email_verified_at', null)
if (deleteError) {
log.error('could not revoke pending bankid identity', {
userId,
code: deleteError.code,
message: deleteError.message,
})
return false
}
const { error: metaError } = await supabase.auth.admin.updateUserById(userId, {
// null removes the key under GoTrue's merge semantics and is falsy if the
// metadata is ever replaced wholesale instead.
app_metadata: { ...(priorAppMetadata ?? {}), bankid_pending: null },
})
if (metaError) {
log.error('could not clear bankid_pending after revoking identity', {
userId,
code: metaError.code,
message: metaError.message,
})
}
return true
}
@@ -1,5 +1,25 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { listOrderRefunds, type WooCredentials } from '../lib/api-client'
import {
INVALID_STORE_URL_CODE,
UNSAFE_STORE_URL_CODE,
listOrderRefunds,
testConnectionAndFetchStoreInfo,
wcGet,
WooCommerceApiError,
type WooCredentials,
} from '../lib/api-client'
// The SSRF guard resolves DNS. Stub the validator (same seam the webhook
// dispatcher tests use) so tests are deterministic and offline; a dedicated
// test below flips it to a private-address verdict.
const guard = vi.hoisted(() => ({ validateUrl: vi.fn() }))
vi.mock('@/lib/webhooks/url-guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/webhooks/url-guard')>()
return {
...actual,
validateWebhookUrl: (...args: unknown[]) => guard.validateUrl(...args),
}
})
const CREDS: WooCredentials = {
storeUrl: 'https://shop.example.se',
@@ -28,12 +48,114 @@ const fetchMock = vi.fn()
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
guard.validateUrl.mockReset()
guard.validateUrl.mockImplementation(async (rawUrl: string) => ({
ok: true,
hostname: new URL(rawUrl).hostname,
resolvedAddresses: ['203.0.113.10'],
}))
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('wcGet: outbound URL guard', () => {
it('happy path: Basic auth, no redirect following, and the DNS check runs per request', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 1 }]))
await expect(wcGet(CREDS, '/orders', { per_page: '1' })).resolves.toEqual([{ id: 1 }])
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://shop.example.se/wp-json/wc/v3/orders?per_page=1')
expect(init.redirect).toBe('manual')
expect((init.headers as Record<string, string>).Authorization).toMatch(/^Basic /)
expect(init.signal).toBeInstanceOf(AbortSignal)
expect(guard.validateUrl).toHaveBeenCalledWith(url, undefined)
})
it('refuses a stored store_url that no longer normalises (edited outside the app) before any fetch', async () => {
for (const storeUrl of ['http://shop.example.se', 'https://10.0.0.5', 'https://localhost:8080', 'not a url']) {
const error = await wcGet({ ...CREDS, storeUrl }, '/orders').catch((e) => e)
expect(error).toBeInstanceOf(WooCommerceApiError)
expect((error as WooCommerceApiError).wooCode).toBe(INVALID_STORE_URL_CODE)
expect((error as WooCommerceApiError).status).toBe(0)
expect((error as WooCommerceApiError).message).toMatch(/reconnect the store/)
}
expect(fetchMock).not.toHaveBeenCalled()
expect(guard.validateUrl).not.toHaveBeenCalled()
})
it('canonicalises a cosmetically different stored URL instead of refusing it', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([]))
await wcGet({ ...CREDS, storeUrl: 'https://Shop.Example.se/' }, '/orders')
expect(fetchMock.mock.calls[0][0]).toBe('https://shop.example.se/wp-json/wc/v3/orders')
})
it('refuses a public hostname that resolves to a private address, without retrying', async () => {
guard.validateUrl.mockResolvedValue({
ok: false,
reason: 'private_address',
detail: 'Resolved address 10.1.2.3 for shop.example.se is not publicly routable (private_address).',
})
const error = await wcGet(CREDS, '/orders').catch((e) => e)
expect(error).toBeInstanceOf(WooCommerceApiError)
expect((error as WooCommerceApiError).wooCode).toBe(UNSAFE_STORE_URL_CODE)
expect((error as WooCommerceApiError).message).toMatch(/10\.1\.2\.3/)
// Critically: no socket was opened and the backoff schedule was not spent.
expect(fetchMock).not.toHaveBeenCalled()
expect(guard.validateUrl).toHaveBeenCalledTimes(1)
})
it('treats a redirect from the store as a failure, not a hop, and does not retry it', async () => {
fetchMock.mockResolvedValueOnce(
new Response(null, { status: 302, headers: { Location: 'http://169.254.169.254/latest/' } }),
)
const error = await wcGet(CREDS, '/orders').catch((e) => e)
expect(error).toBeInstanceOf(WooCommerceApiError)
expect((error as WooCommerceApiError).wooCode).toBe(UNSAFE_STORE_URL_CODE)
expect((error as WooCommerceApiError).message).toMatch(/redirects are never followed/)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('still falls back to query-string credentials on a 401 (header-stripping hosts)', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({ code: 'woocommerce_rest_cannot_view' }), { status: 401 }))
.mockResolvedValueOnce(jsonResponse([{ id: 7 }]))
await expect(wcGet(CREDS, '/orders')).resolves.toEqual([{ id: 7 }])
const secondUrl = new URL(fetchMock.mock.calls[1][0] as string)
expect(secondUrl.searchParams.get('consumer_key')).toBe('ck_test')
expect(secondUrl.searchParams.get('consumer_secret')).toBe('cs_test')
expect((fetchMock.mock.calls[1][1] as RequestInit).redirect).toBe('manual')
})
})
describe('testConnectionAndFetchStoreInfo', () => {
it('routes the public /wp-json/ title probe through the guard too', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([{ id: 1 }])) // /orders probe
.mockResolvedValueOnce(jsonResponse([])) // /settings/general
.mockResolvedValueOnce(jsonResponse({})) // /system_status
.mockResolvedValueOnce(jsonResponse({ name: 'Butiken' })) // /wp-json/
const info = await testConnectionAndFetchStoreInfo(CREDS)
expect(info.name).toBe('Butiken')
const [url, init] = fetchMock.mock.calls[3] as [string, RequestInit]
expect(url).toBe('https://shop.example.se/wp-json/')
expect(init.redirect).toBe('manual')
expect(guard.validateUrl).toHaveBeenCalledTimes(4)
})
})
describe('listOrderRefunds', () => {
it('terminates on an empty page, not a short one (hosts may cap per_page)', async () => {
fetchMock
@@ -107,7 +107,10 @@ export default function WooCommerceSettingsPanel() {
if (connected) {
toast({ title: t('connected_toast_title'), description: t('connected_toast_description') })
} else if (error) {
const message = error === 'denied' ? t('error_denied') : t('error_generic')
const message =
error === 'denied' ? t('error_denied')
: error === 'wrong_user' ? t('error_wrong_user')
: t('error_generic')
toast({ title: t('connect_failed_title'), description: message, variant: 'destructive' })
}
router.replace('/import?mode=woocommerce')
@@ -1,3 +1,4 @@
import { isUnsafeUrlError, safeFetch } from '@/lib/http/safe-fetch'
import type { WooOrder, WooRefund, WooStoreInfo } from '../types'
/**
@@ -9,6 +10,14 @@ import type { WooOrder, WooRefund, WooStoreInfo } from '../types'
* query-string credential fallback; that fallback is why plain-http stores
* are refused outright (keys in a cleartext URL are a credentials leak).
*
* The store URL is tenant input that the server connects to, and members can
* write `woocommerce_connections.store_url` directly through PostgREST
* (bypassing the connect route's normalisation), so every request here
* re-normalises the stored URL and goes through `safeFetch`: public
* addresses only, checked at request time, and no redirects followed. The
* nightly cron runs this under the service role, which is exactly the
* network position an SSRF would want.
*
* Typical WooCommerce hosts are slow shared PHP boxes: requests run
* sequentially, pages are capped at 100 rows, and 429/5xx responses get a
* short exponential backoff before the error is surfaced.
@@ -53,9 +62,10 @@ export function isRevokedCredentialsError(error: unknown): boolean {
/**
* Hostnames the server must never fetch: the store URL is user input that we
* probe server-side, so loopback/link-local/private ranges and internal
* naming conventions are refused outright (SSRF guard). Hostname-level only:
* a public DNS name resolving to a private address is not caught here, which
* matches the app's other outbound-URL surfaces.
* naming conventions are refused outright (SSRF guard). Hostname-level only,
* and cheap enough to run synchronously at connect time; a public DNS name
* resolving to a private address is caught later by `safeFetch`, which
* resolves and classifies every A/AAAA record at request time.
*/
function isDisallowedHost(hostname: string): boolean {
const h = hostname.toLowerCase()
@@ -99,6 +109,30 @@ export function normalizeStoreUrl(input: string): string | null {
return `https://${url.host.toLowerCase()}${path}`
}
/** Error code on a WooCommerceApiError when the stored store URL fails re-normalisation. */
export const INVALID_STORE_URL_CODE = 'accounted_invalid_store_url'
/** Error code on a WooCommerceApiError when the SSRF guard refused to connect. */
export const UNSAFE_STORE_URL_CODE = 'accounted_unsafe_store_url'
/**
* Re-run the connect-time normalisation on the STORED store URL at use time.
* The connect route normalises what the user typed, but a member can PATCH
* `store_url` straight into the row through PostgREST, so the value in the
* database is not trusted to still be an https public-host origin. A row that
* fails here is refused with a clear, non-retryable error instead of fetched.
*/
function storeOriginOf(creds: WooCredentials): string {
const normalized = normalizeStoreUrl(creds.storeUrl)
if (!normalized) {
throw new WooCommerceApiError(
`WooCommerce store URL is not a valid https store address (${creds.storeUrl}); reconnect the store`,
0,
INVALID_STORE_URL_CODE,
)
}
return normalized
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -109,7 +143,7 @@ function buildUrl(
params: Record<string, string>,
credentialsInQuery: boolean,
): string {
const url = new URL(`${creds.storeUrl}/wp-json/wc/v3${path}`)
const url = new URL(`${storeOriginOf(creds)}/wp-json/wc/v3${path}`)
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
if (credentialsInQuery) {
url.searchParams.set('consumer_key', creds.consumerKey)
@@ -129,12 +163,31 @@ async function requestOnce(
const basic = Buffer.from(`${creds.consumerKey}:${creds.consumerSecret}`).toString('base64')
headers.Authorization = `Basic ${basic}`
}
return fetch(buildUrl(creds, path, params, credentialsInQuery), {
// safeFetch: public address only (checked now, not at connect time), no
// redirects. A 3xx from the store is a failure, never a hop.
return safeFetch(buildUrl(creds, path, params, credentialsInQuery), {
headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
}
/**
* Map a failure from requestOnce to the error the retry loop should see.
* Guard refusals (bad stored URL, private address, redirect) are terminal:
* retrying the same URL cannot succeed and must not spend the backoff budget.
*/
function asTerminalGuardError(err: unknown): WooCommerceApiError | null {
if (err instanceof WooCommerceApiError) return err
if (isUnsafeUrlError(err)) {
return new WooCommerceApiError(
`WooCommerce store refused by outbound URL guard: ${err.detail}`,
0,
UNSAFE_STORE_URL_CODE,
)
}
return null
}
async function parseError(response: Response): Promise<WooCommerceApiError> {
let wooCode: string | null = null
let detail = ''
@@ -168,6 +221,8 @@ export async function wcGet<T>(
try {
response = await requestOnce(creds, path, params, credentialsInQuery)
} catch (err) {
const terminal = asTerminalGuardError(err)
if (terminal) throw terminal
// Network/timeout errors: retry on the same backoff schedule.
lastError = new WooCommerceApiError(
`WooCommerce request failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -303,7 +358,7 @@ export async function testConnectionAndFetchStoreInfo(
try {
// The WP REST index is public and carries the site title.
const response = await fetch(`${creds.storeUrl}/wp-json/`, {
const response = await safeFetch(`${storeOriginOf(creds)}/wp-json/`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})