From f3e4fdcf32815d8866a453ef5f442e801d4a7327 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 21 Aug 2026 08:33:34 +0200 Subject: [PATCH] fix(providers): stop the Fortnox reconnect loop, and make the attachment scopes opt-in (#1761) The Fortnox document import needs the archive and connectfile scopes, which the registered Fortnox app does not have. Since #1549 pulled them out of the connect request (they broke every connect with invalid_scope before login), every attachment call fails and the user was told "Koppla om Fortnox och godkann behorigheterna", under a button that reruns an authorize URL still not asking for those scopes. Klura AB followed that loop four times and bought the Fortnox Arkiv module trying to satisfy it. Prod evidence: no Fortnox attachment has ever imported, across 166 companies and 24 consents since the feature shipped, and no live token carries the scopes. The error and the scope list now derive from one flag, FORTNOX_DOCUMENT_SCOPES_APPROVED. While it is false a permission failure maps to a new PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, which says the permission is missing on our side, that reconnecting will not help, and that the rest of the migration came through; the card offers no button, because no user action can succeed. The attachment scopes also become an opt-in consent rather than part of every connect. Fortnox derives customer licence requirements from what an integration requests, so asking everyone for Arkivplats would put a licence in front of customers who never import a receipt; and keeping it off the default connect caps the blast radius of a wrong portal registration at the underlag flow rather than every Fortnox connection. buildFortnoxAuthUrl already took per-call scopes, provider-client simply never passed any, so this threads documentScopes from that one button through /connect into the authorize URL. A document consent is always a superset of an ordinary one: the callback overwrites the consent's tokens in place, so a narrower grant would revoke the migration's own ledger access. Pinned by a test that holds either way the flag is set, alongside one for the 400-with-behorighet answer that six companies hit between 08-13 and 08-19 and saw only a generic retry for. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- DECISIONS.md | 1 + .../general/ArcimMigrationWorkspace.tsx | 52 +++++++----- .../arcim-document-import-flow.test.ts | 31 +++++++ .../general/arcim-document-import-flow.ts | 7 ++ .../__tests__/fortnox-document-scopes.test.ts | 80 +++++++++++++++++++ .../__tests__/import-documents-route.test.ts | 38 ++++++++- .../__tests__/import-documents.test.ts | 30 +++++++ .../__tests__/oauth-callback-state.test.ts | 42 +++++++++- extensions/general/arcim-migration/index.ts | 36 +++++++-- .../arcim-migration/lib/provider-client.ts | 16 +++- lib/errors/structured-errors.ts | 7 ++ lib/providers/fortnox/__tests__/oauth.test.ts | 42 +++++++++- lib/providers/fortnox/oauth.ts | 49 +++++++++--- messages/en.json | 1 + messages/sv.json | 1 + 15 files changed, 395 insertions(+), 38 deletions(-) create mode 100644 extensions/general/arcim-migration/__tests__/fortnox-document-scopes.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index fcceefaa..c099a2a8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1135,3 +1135,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] #1674 audit fixed the in-repo disclosures only (privacy sub-processor Bedrock row now names Anthropic as model vendor, replay paragraph states the deny-by-default guarantee, both locked by app/(public)/privacy/__tests__): Anthropic was deliberately NOT added as its own sub-processor row because no code path sends data to Anthropic (hosted uses Bedrock in eu-north-1, lib/ai/provider.ts); the artifact the prospect actually read (published DPA PDF or marketing security page listing Anthropic/OpenAI in the US) lives outside this repo and needs founder/legal action, as does any underbitraede-list wording. [2026-08-19] #1674 audit fixed the in-repo disclosures only (privacy sub-processor Bedrock row now says AI requests go to Amazon Bedrock and the models are Anthropics Claude running inside Bedrock, replay paragraph states the deny-by-default guarantee, both locked by app/(public)/privacy/__tests__): Anthropic was not added as its own sub-processor row because the HOSTED posture is Bedrock by credential precedence (lib/ai/provider.ts: static AWS keys select Bedrock, region is AWS_REGION with eu-north-1 as the default, not a guarantee); a direct Anthropic API path DOES exist in code for self-hosted deployments (AI_PROVIDER=anthropic, or ANTHROPIC_API_KEY without static AWS keys), so "no code path sends data to Anthropic" would be false and the page asserts nothing about Anthropics underbitraede status either way; whether Anthropic is an underbitraede, plus aligning the published DPA PDF / marketing security page (which listed Anthropic/OpenAI in the US), is founder/legal action outside this repo. [2026-08-20] Reverted #1765: the company switcher is NOT mounted at the top of the desktop sidebar. Founder call after seeing it live: switching belongs in the bottom user block only (the UserMenu flyout), so the sidebar top stays brand + collapse and the nav starts immediately below. #1664's "one-click from the top" framing is therefore declined, not merely unimplemented; the logo title tooltip went back with the revert since it shipped inside the same commit. Do not re-add a top-of-sidebar switcher from #1664 without a new founder decision. +[2026-08-20] Fortnox voucher-attachment scopes (Arkivplats + Koppla filer) are requested per authorize call from the underlag follow-up only, never from an ordinary connect, and gated on FORTNOX_DOCUMENT_SCOPES_APPROVED in lib/providers/fortnox/oauth.ts (the portal-registration switch). Two reasons: Fortnox derives customer licence requirements from what the integration requests, so an all-connects request would put an Arkivplats licence in front of customers who never import a receipt (the portal says so in as many words); and a scope the registered app lacks makes authorize reject with invalid_scope before login, so keeping it off the default connect caps the blast radius at the underlag flow instead of every Fortnox connection (incident 2026-08-13). A document consent is always a superset of an ordinary one, because the callback overwrites the consent's tokens in place and a narrower grant would revoke the migration's own ledger access. While the flag is false the attachment 403 reports PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE with no action offered, instead of reconnect advice for a permission we never ask for: that advice sent Klura AB around the OAuth loop four times and to buy the Fortnox Arkiv module for nothing (support case 2026-08-20). Portal registration alone changes nothing observable, which is why turning the scopes on and back off that day neither caused nor fixed the error. diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index a43e86dd..42ddc71c 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -35,6 +35,7 @@ import { arcimDocumentImportReducer, documentOAuthProblemFromReason, parseArcimDocumentOAuthResume, + PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, requestArcimDocumentImport, resolveArcimDocumentFollowUpProvider, watchArcimOAuthPopup, @@ -1586,11 +1587,17 @@ function DocumentImportFollowUp({ const reconnectRequired = state.problem?.reconnectRequired === true const discoveryFailed = state.phase === 'discovery-error' + // Fortnox has not granted the file permissions to the integration itself, so + // neither reconnecting nor retrying can succeed: state it and offer nothing. + const scopesUnavailable = + state.problem?.code === PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE return (
{title}

- {state.problem?.message + {scopesUnavailable + ? t('ext_arcim_documents_scope_unavailable') + : state.problem?.message ? state.problem.message : reconnectRequired ? t('ext_arcim_documents_scope_error') @@ -1612,21 +1619,23 @@ function DocumentImportFollowUp({ })}

)} - + {!scopesUnavailable && ( + + )}
) } @@ -2192,7 +2201,7 @@ export default function ArcimMigrationWorkspace({ const handleReconnect = useCallback(async ( provider: ArcimProvider, existingConsentId: string, - options?: { onFailure?: () => void }, + options?: { onFailure?: () => void; documentScopes?: boolean }, ) => { setError(null) setAuthExpired(false) @@ -2215,7 +2224,11 @@ export default function ArcimMigrationWorkspace({ const res = await fetch('/api/extensions/ext/arcim-migration/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider, reconnect: true }), + body: JSON.stringify({ + provider, + reconnect: true, + documentScopes: options?.documentScopes === true, + }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) @@ -2328,6 +2341,9 @@ export default function ArcimMigrationWorkspace({ storeDocumentOAuthResume(reconnectAction) dispatchDocumentImport({ type: 'reconnect-started' }) void handleReconnect('fortnox', consentId, { + // The whole point of this reconnect is the attachment permissions, so + // this is the one path that asks Fortnox for them. + documentScopes: true, onFailure: () => { dispatchDocumentImport( reconnectAction === 'discover' diff --git a/components/extensions/general/__tests__/arcim-document-import-flow.test.ts b/components/extensions/general/__tests__/arcim-document-import-flow.test.ts index edf29a3d..24ed65d9 100644 --- a/components/extensions/general/__tests__/arcim-document-import-flow.test.ts +++ b/components/extensions/general/__tests__/arcim-document-import-flow.test.ts @@ -3,6 +3,7 @@ import { ARCIM_DOCUMENT_OAUTH_RESUME_KEY, INITIAL_ARCIM_DOCUMENT_IMPORT_STATE, PROVIDER_DOCUMENT_SCOPES_REQUIRED, + PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, ArcimDocumentImportRequestError, arcimDocumentImportReducer, documentOAuthProblemFromReason, @@ -168,6 +169,36 @@ describe('Fortnox document follow-up state', () => { reconnectRequired: true, }) }) + + // The scopes are missing from the connect request itself, so offering a + // reconnect would loop the user forever (Klura AB, 2026-08-20). + it('never offers a reconnect when the scopes are unavailable to the integration', async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, + message: 'scopes unavailable', + requestId: 'req_unavailable', + }, + }), + { status: 403, headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const error = await requestArcimDocumentImport( + 'consent-1', + true, + fetcher, + ).catch((caught) => caught) + + expect(error).toBeInstanceOf(ArcimDocumentImportRequestError) + expect(error.problem).toEqual({ + code: PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, + requestId: 'req_unavailable', + reconnectRequired: false, + }) + }) }) describe('document import endpoint request', () => { diff --git a/components/extensions/general/arcim-document-import-flow.ts b/components/extensions/general/arcim-document-import-flow.ts index 9a692173..2e83e31a 100644 --- a/components/extensions/general/arcim-document-import-flow.ts +++ b/components/extensions/general/arcim-document-import-flow.ts @@ -4,6 +4,13 @@ export const ARCIM_DOCUMENT_IMPORT_ENDPOINT = export const PROVIDER_DOCUMENT_SCOPES_REQUIRED = 'PROVIDER_DOCUMENT_SCOPES_REQUIRED' +/** + * The connect request does not ask Fortnox for Arkiv and Koppla fil at all, + * so no reconnect can grant them: the error offers no action, only the truth. + */ +export const PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE = + 'PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE' + export const ARCIM_DOCUMENT_OAUTH_RESUME_KEY = 'arcim-document-oauth-resume' diff --git a/extensions/general/arcim-migration/__tests__/fortnox-document-scopes.test.ts b/extensions/general/arcim-migration/__tests__/fortnox-document-scopes.test.ts new file mode 100644 index 00000000..3c8ef0eb --- /dev/null +++ b/extensions/general/arcim-migration/__tests__/fortnox-document-scopes.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest' + +/** + * The voucher-attachment scopes (Arkivplats + Koppla filer) are opt-in per + * authorize call rather than part of every Fortnox connect. Two reasons, both + * of which have already cost us once: + * + * 1. Fortnox derives its customer licence requirements from what the + * integration requests, so asking every customer for Arkivplats would put + * a licence in front of people who never import a receipt. + * 2. A scope the registered app lacks makes authorize reject with + * invalid_scope before login. Keeping it off the ordinary connect means + * the blast radius of getting it wrong is the underlag flow, not every + * Fortnox connection in production (incident 2026-08-13). + */ + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => ({ from: vi.fn() }), + createServiceClientNoCookies: () => ({ from: vi.fn() }), +})) + +vi.mock('@/lib/providers/oauth-config', () => ({ + getOAuthConfig: () => ({ + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://accounted.example.test/callback', + }), +})) + +import { + fortnoxConsentScopes, + FORTNOX_DOCUMENT_SCOPES, +} from '@/lib/providers/fortnox/oauth' +import { getAuthUrl } from '../lib/provider-client' + +const scopesOf = (url: string) => + new URL(url).searchParams.get('scope')?.split(' ') ?? [] + +describe('Fortnox document scopes are opt-in', () => { + it('leaves the attachment scopes out of an ordinary consent', () => { + expect(fortnoxConsentScopes()).not.toContain('archive') + expect(fortnoxConsentScopes()).not.toContain('connectfile') + }) + + // The OAuth callback overwrites the consent's tokens in place, so a document + // consent that dropped the base scopes would silently revoke the migration's + // own access to the ledger it just imported. + it('never drops the base scopes when asking for the attachment scopes', () => { + const base = fortnoxConsentScopes() + const withDocuments = fortnoxConsentScopes({ documents: true }) + + for (const scope of base) { + expect(withDocuments).toContain(scope) + } + expect(withDocuments.length).toBeGreaterThanOrEqual(base.length) + expect(new Set(withDocuments).size).toBe(withDocuments.length) + }) + + it('names both attachment scopes, so the portal registration has a source of truth', () => { + expect(FORTNOX_DOCUMENT_SCOPES).toEqual(['archive', 'connectfile']) + }) + + it('builds the ordinary connect URL without the attachment scopes', async () => { + const { url } = await getAuthUrl('fortnox', 'state-1', 'https://cb.test') + + expect(scopesOf(url)).toEqual(fortnoxConsentScopes()) + expect(scopesOf(url)).not.toContain('archive') + }) + + it('builds the underlag reconnect URL from the document consent scopes', async () => { + const { url } = await getAuthUrl('fortnox', 'state-2', 'https://cb.test', { + documentScopes: true, + }) + + expect(scopesOf(url)).toEqual(fortnoxConsentScopes({ documents: true })) + for (const scope of fortnoxConsentScopes()) { + expect(scopesOf(url)).toContain(scope) + } + }) +}) diff --git a/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts b/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts index 07d4f3a1..8b691c29 100644 --- a/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts +++ b/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts @@ -18,6 +18,19 @@ vi.mock('../lib/import-documents', () => { } }) +const fortnoxOAuth = vi.hoisted(() => ({ documentScopesApproved: false })) + +vi.mock('@/lib/providers/fortnox/oauth', async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + get FORTNOX_DOCUMENT_SCOPES_APPROVED() { + return fortnoxOAuth.documentScopesApproved + }, + } +}) + vi.mock('../lib/provider-client', () => { class ProviderTokenInvalidError extends Error { constructor( @@ -106,6 +119,7 @@ describe('POST /import-documents', () => { beforeEach(() => { vi.clearAllMocks() eventBus.clear() + fortnoxOAuth.documentScopesApproved = false }) it('passes dry-run discovery through without storing documents', async () => { @@ -138,7 +152,8 @@ describe('POST /import-documents', () => { ) }) - it('returns an actionable 403 when Fortnox lacks archive/connectfile scopes', async () => { + it('asks the user to reconnect only once the connect request carries the scopes', async () => { + fortnoxOAuth.documentScopesApproved = true ;(importProviderDocuments as Mock).mockRejectedValue( new FortnoxDocumentScopesRequiredError(), ) @@ -153,6 +168,27 @@ describe('POST /import-documents', () => { expect(body.error.message).toContain('Koppla om Fortnox') expect(body.error.message_en).toContain('Reconnect Fortnox') }) + + // Klura AB, 2026-08-20: the connect request does not ask Fortnox for Arkiv + // and Koppla fil at all, so the reconnect advice sent the user around a loop + // four times (and to buy the Fortnox Arkiv module) for nothing. + it('says the permission is missing on our side while the scopes are unapproved', async () => { + fortnoxOAuth.documentScopesApproved = false + ;(importProviderDocuments as Mock).mockRejectedValue( + new FortnoxDocumentScopesRequiredError(), + ) + + const response = await handler(request(false), buildContext()) + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string; message_en?: string } + }>(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE') + expect(body.error.message).not.toContain('Koppla om Fortnox') + expect(body.error.message).toContain('Att koppla om hjälper inte') + expect(body.error.message_en).toContain('Reconnecting will not help') + }) }) describe('POST /submit-token Bokio error mapping', () => { diff --git a/extensions/general/arcim-migration/__tests__/import-documents.test.ts b/extensions/general/arcim-migration/__tests__/import-documents.test.ts index 563b6568..d46389f2 100644 --- a/extensions/general/arcim-migration/__tests__/import-documents.test.ts +++ b/extensions/general/arcim-migration/__tests__/import-documents.test.ts @@ -479,6 +479,36 @@ describe('importProviderDocuments', () => { ).rejects.toThrow('Fortnox consent lacks archive/connectfile scope: reconnect required') }) + // Six companies hit exactly this between 2026-08-13 and 08-19 and were told + // "kunde inte importera underlag, försök igen", with a retry that could not + // work: Fortnox answers an unscoped attachment listing with 400 plus a + // behörighet message, not only with 403. + it('treats a Fortnox 400 that names the missing permission as the same failure', async () => { + const supabase = wireFortnox() + mockFetchFortnoxFinancialYears.mockRejectedValue( + new FortnoxApiError( + 'bad request', + 400, + JSON.stringify({ + ErrorInformation: { + error: 1, + message: 'Otillräcklig behörighet för att utföra anropet', + code: 2000663, + }, + }), + ), + ) + + await expect( + importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }), + ).rejects.toThrow('Fortnox consent lacks archive/connectfile scope: reconnect required') + }) + it('explains that a Fortnox archive-download 403 requires reconnecting', async () => { const supabase = wireFortnox() mockDownloadFortnoxArchiveFile.mockRejectedValue( diff --git a/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts b/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts index c43f64ce..2c19e68b 100644 --- a/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts +++ b/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts @@ -323,7 +323,47 @@ describe('OAuth redirect_uri symmetry between authorize and exchange', () => { ) expect(res.status).toBe(200) - expect(getAuthUrl).toHaveBeenCalledWith('fortnox', 'otc-code-1', OVERRIDE_URI) + expect(getAuthUrl).toHaveBeenCalledWith( + 'fortnox', + 'otc-code-1', + OVERRIDE_URI, + // A first connect never asks for the voucher-attachment scopes: those + // carry a Fortnox licence requirement and belong to the opt-in underlag + // reconnect only. + { documentScopes: undefined }, + ) + }) + + // The underlag follow-up is the only caller allowed to widen the consent. + it('reconnect asks for the attachment scopes only when the underlag flow requests them', async () => { + ;(listConsents as Mock).mockResolvedValue([ + { id: 'consent-1', provider: 'fortnox', status: 1 }, + ]) + + const reconnect = (documentScopes?: boolean) => + connectHandler( + createMockRequest('http://localhost/api/extensions/ext/arcim-migration/connect', { + method: 'POST', + body: { provider: 'fortnox', reconnect: true, ...(documentScopes === undefined ? {} : { documentScopes }) }, + }), + connectCtx(), + ) + + expect((await reconnect(true)).status).toBe(200) + expect(getAuthUrl).toHaveBeenLastCalledWith( + 'fortnox', + 'otc-code-1', + OVERRIDE_URI, + { documentScopes: true }, + ) + + expect((await reconnect()).status).toBe(200) + expect(getAuthUrl).toHaveBeenLastCalledWith( + 'fortnox', + 'otc-code-1', + OVERRIDE_URI, + { documentScopes: false }, + ) }) it('exchange leg passes the SAME env-override redirect URI to exchangeAuthToken', async () => { diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index f32d8145..5352a8fe 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -34,6 +34,7 @@ import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/ import { loadMappings, generateImportPreview, executeSIEImport, findOverlappingPeriodImports } from '@/lib/import/sie-import' 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 { classifyProviderError } from '@/lib/providers/with-provider-call' import { FortnoxApiError, fortnoxErrorMessage } from '@/lib/providers/fortnox/client' @@ -119,7 +120,11 @@ function resolveArcimCallbackUrl(provider: ArcimProvider | ProviderName): string * consent_id, so re-running OAuth against the same consent overwrites a dead * refresh-token pair in place: no disconnect/recreate needed. */ -async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): Promise { +async function buildArcimOAuthUrl( + consentId: string, + provider: ArcimProvider, + options?: { documentScopes?: boolean }, +): Promise { // 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. @@ -131,7 +136,9 @@ async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): P // above. It deliberately encodes NOTHING. The previous base64url JSON payload // was attacker-authored input the callback trusted, so anyone who learned a // consent id could redirect their own provider tokens onto that consent. - const { url } = await getAuthUrl(provider, otc.code, callbackUrl) + const { url } = await getAuthUrl(provider, otc.code, callbackUrl, { + documentScopes: options?.documentScopes, + }) return url } @@ -259,11 +266,23 @@ export const arcimMigrationExtension: Extension = { const companyId = ctx?.companyId ?? user.id - const { provider, companyName, orgNumber, reconnect } = await request.json() as { + const { + provider, + companyName, + orgNumber, + reconnect, + documentScopes, + } = await request.json() as { provider: ArcimProvider companyName?: string orgNumber?: string reconnect?: boolean + /** + * Reconnect specifically to grant the voucher-attachment scopes. + * Only the underlag follow-up sets it, so an ordinary connect never + * asks the customer for Arkivplats and Koppla filer. + */ + documentScopes?: boolean } if (!provider) { @@ -300,7 +319,9 @@ 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, { + documentScopes: documentScopes === true, + }) return NextResponse.json({ consentId: stale.id, authType: 'oauth', @@ -1386,8 +1407,13 @@ export const arcimMigrationExtension: Extension = { error instanceof FortnoxApiError ? error.body?.slice(0, 500) : undefined, }) if (error instanceof FortnoxDocumentScopesRequiredError) { + // Reconnecting only helps once the connect request actually asks + // for Arkiv and Koppla fil. While it does not, say so plainly + // instead of sending the user around a loop that cannot succeed. return errorResponseFromCode( - 'PROVIDER_DOCUMENT_SCOPES_REQUIRED', + FORTNOX_DOCUMENT_SCOPES_APPROVED + ? 'PROVIDER_DOCUMENT_SCOPES_REQUIRED' + : 'PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE', moduleLog, { status: 403 }, ) diff --git a/extensions/general/arcim-migration/lib/provider-client.ts b/extensions/general/arcim-migration/lib/provider-client.ts index 677c60e5..52b7cc90 100644 --- a/extensions/general/arcim-migration/lib/provider-client.ts +++ b/extensions/general/arcim-migration/lib/provider-client.ts @@ -10,7 +10,10 @@ import { randomBytes } from 'node:crypto' import { createServiceClient } from '@/lib/supabase/server' import type { ProviderName } from '@/lib/providers/types' import { getOAuthConfig } from '@/lib/providers/oauth-config' -import { buildFortnoxAuthUrl } from '@/lib/providers/fortnox/oauth' +import { + buildFortnoxAuthUrl, + fortnoxConsentScopes, +} from '@/lib/providers/fortnox/oauth' import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth' import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth' import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth' @@ -314,6 +317,12 @@ export async function getAuthUrl( provider: ProviderName, state?: string, redirectUri?: string, + /** + * Ask Fortnox for the voucher-attachment scopes as well. Opt-in, so only a + * user who wants their underlag is put in front of the extra permissions + * (and their licence requirements). + */ + options?: { documentScopes?: boolean }, ): Promise<{ url: string }> { const config = getOAuthConfig(provider) @@ -323,7 +332,10 @@ export async function getAuthUrl( : config if (provider === 'fortnox') { - const url = buildFortnoxAuthUrl(effectiveConfig, { state }) + const url = buildFortnoxAuthUrl(effectiveConfig, { + state, + scopes: fortnoxConsentScopes({ documents: options?.documentScopes }), + }) return { url } } diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index cd6ea4eb..283f47d2 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2037,6 +2037,13 @@ const PROVIDER_MIGRATION: Record = { message_en: 'The Fortnox connection lacks Archive and Connect file access. Reconnect Fortnox and approve those permissions to import documents.', }, + PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE: { + httpStatus: 403, + message_sv: + 'Filimport från Fortnox är inte påslagen än: behörigheterna Arkiv och Koppla fil saknas för Accounted-integrationen hos Fortnox. Att koppla om hjälper inte, vi aktiverar det så snart behörigheten är på plats. Allt annat i migreringen är importerat.', + message_en: + 'Fortnox file import is not enabled yet: the Archive and Connect file permissions are missing for the Accounted integration at Fortnox. Reconnecting will not help; we enable this as soon as the permission is in place. Everything else in the migration was imported.', + }, PROVIDER_DISCONNECT_FAILED: { httpStatus: 500, message_sv: 'Frånkoppling från leverantören misslyckades.', diff --git a/lib/providers/fortnox/__tests__/oauth.test.ts b/lib/providers/fortnox/__tests__/oauth.test.ts index cb4acc51..ba58b49d 100644 --- a/lib/providers/fortnox/__tests__/oauth.test.ts +++ b/lib/providers/fortnox/__tests__/oauth.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { buildFortnoxAuthUrl } from '../oauth'; +import { + buildFortnoxAuthUrl, + fortnoxConsentScopes, + FORTNOX_DOCUMENT_SCOPES, + FORTNOX_DOCUMENT_SCOPES_APPROVED, +} from '../oauth'; describe('Fortnox OAuth scopes', () => { // Pins the 2026-08-13 incident fix: the registered Fortnox app does not @@ -22,4 +27,39 @@ describe('Fortnox OAuth scopes', () => { expect(scopes).not.toContain('archive'); expect(scopes).not.toContain('connectfile'); }); + + // The document-import error message is derived from this flag, so that a + // user is never told to reconnect for a permission the connect request does + // not ask for. Flipping it here without enabling the scopes in the Fortnox + // Developer Portal reintroduces the 2026-08-13 invalid_scope outage. + it('keeps the document scopes flagged as not approved, and names both of them', () => { + expect(FORTNOX_DOCUMENT_SCOPES_APPROVED).toBe(false); + expect(FORTNOX_DOCUMENT_SCOPES).toEqual(['archive', 'connectfile']); + }); + + // Even once the portal registration lands, opting in must never cost the + // consent its ledger access: the callback overwrites its tokens in place. + it('keeps a document consent a superset of an ordinary one', () => { + const withDocuments = fortnoxConsentScopes({ documents: true }); + for (const scope of fortnoxConsentScopes()) { + expect(withDocuments).toContain(scope); + } + }); + + it('requests the document scopes only when they are explicitly passed in', () => { + const url = new URL( + buildFortnoxAuthUrl( + { + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://accounted.example.test/callback', + }, + { scopes: ['bookkeeping', ...FORTNOX_DOCUMENT_SCOPES] }, + ), + ); + const scopes = new Set(url.searchParams.get('scope')?.split(' ') ?? []); + + expect(scopes).toContain('archive'); + expect(scopes).toContain('connectfile'); + }); }); diff --git a/lib/providers/fortnox/oauth.ts b/lib/providers/fortnox/oauth.ts index bea5288d..f378ad6b 100644 --- a/lib/providers/fortnox/oauth.ts +++ b/lib/providers/fortnox/oauth.ts @@ -6,23 +6,50 @@ import { OAUTH_REVOKE_TIMEOUT_MS, } from '@/lib/http/fetch-with-timeout'; -const DEFAULT_SCOPES = [ +const BASE_SCOPES = [ 'companyinformation', 'invoice', 'supplierinvoice', 'customer', 'supplier', 'bookkeeping', - // 'archive' and 'connectfile' (voucher attachment import) must NOT be - // requested until the registered Fortnox app has them approved in the - // Fortnox Developer Portal: requesting a scope the app lacks makes the - // authorize endpoint reject with invalid_scope BEFORE login, which kills - // every Fortnox connect (prod incident 2026-08-13). The document import - // detects the missing scopes at runtime (403 becomes - // PROVIDER_DOCUMENT_SCOPES_REQUIRED) and surfaces a reconnect follow-up - // instead of failing the migration. ]; +/** Arkivplats + Koppla filer: what the voucher attachment import reads. */ +export const FORTNOX_DOCUMENT_SCOPES = ['archive', 'connectfile']; + +/** + * Whether the registered Fortnox app has Arkivplats and Koppla filer enabled in + * the Fortnox Developer Portal (integration 39254). Requesting a scope the app + * lacks makes the authorize endpoint reject with invalid_scope BEFORE login, + * which kills every Fortnox connect (prod incident 2026-08-13), so this stays + * false until the portal registration has both. Flip it in the same change that + * enables them there. + * + * It gates the opt-in document consent below and the document-import error + * message, never the ordinary connect: a user is never told to reconnect for a + * permission we don't ask for (support case Klura AB, 2026-08-20). + */ +export const FORTNOX_DOCUMENT_SCOPES_APPROVED: boolean = false; + +/** + * The scopes a Fortnox consent is minted with. The document scopes are opt-in + * per authorize call, because Fortnox derives its customer licence + * requirements from what an integration requests: a customer who never imports + * receipts should not be asked to hold an Arkivplats licence to connect at all. + * + * The base scopes always ride along. The OAuth callback overwrites the + * consent's tokens in place, so a document consent minted from the two extra + * scopes alone would strip the migration's own access to the ledger. + */ +export function fortnoxConsentScopes(options?: { documents?: boolean }): string[] { + const withDocuments = + options?.documents === true && FORTNOX_DOCUMENT_SCOPES_APPROVED; + return withDocuments + ? [...BASE_SCOPES, ...FORTNOX_DOCUMENT_SCOPES] + : [...BASE_SCOPES]; +} + export function buildFortnoxAuthUrl( config: OAuthConfig, options?: { scopes?: string[]; state?: string }, @@ -34,7 +61,9 @@ export function buildFortnoxAuthUrl( access_type: 'offline', }); - const scopes = options?.scopes?.length ? options.scopes : DEFAULT_SCOPES; + const scopes = options?.scopes?.length + ? options.scopes + : fortnoxConsentScopes(); params.set('scope', scopes.join(' ')); if (options?.state) { diff --git a/messages/en.json b/messages/en.json index f8fd8b32..a5a8708e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5442,6 +5442,7 @@ "ext_arcim_documents_discovery_error": "The migration is complete, but we could not check the documents in Fortnox. Try again without rerunning the migration.", "ext_arcim_documents_import_error": "The migration is still complete, but the documents could not be imported. Try again; documents already imported will be skipped.", "ext_arcim_documents_scope_error": "The Fortnox connection lacks access to Archive and Connect file. Reconnect Fortnox and approve those permissions to continue.", + "ext_arcim_documents_scope_unavailable": "Fortnox file import is not enabled yet: the Archive and Connect file permissions are missing for the Accounted integration at Fortnox. Reconnecting will not help; we enable this as soon as the permission is in place. Everything else in the migration was imported.", "ext_arcim_documents_reconnect_action": "Reconnect Fortnox", "ext_arcim_documents_retry_discovery": "Check again", "ext_arcim_documents_retry_import": "Try importing again", diff --git a/messages/sv.json b/messages/sv.json index be82d010..e995eca0 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5442,6 +5442,7 @@ "ext_arcim_documents_discovery_error": "Migreringen är klar, men vi kunde inte kontrollera underlagen i Fortnox. Försök igen utan att köra om migreringen.", "ext_arcim_documents_import_error": "Migreringen är fortfarande klar, men underlagen kunde inte importeras. Försök igen; redan importerade underlag hoppas över.", "ext_arcim_documents_scope_error": "Fortnox-anslutningen saknar behörighet till Arkiv och Koppla fil. Koppla om Fortnox och godkänn behörigheterna för att fortsätta.", + "ext_arcim_documents_scope_unavailable": "Filimport från Fortnox är inte påslagen än: behörigheterna Arkiv och Koppla fil saknas för Accounted-integrationen hos Fortnox. Att koppla om hjälper inte, vi aktiverar det så snart behörigheten är på plats. Allt annat i migreringen är importerat.", "ext_arcim_documents_reconnect_action": "Koppla om Fortnox", "ext_arcim_documents_retry_discovery": "Kontrollera igen", "ext_arcim_documents_retry_import": "Försök importera igen",