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) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-21 08:33:34 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 5
parent c5b7716f74
commit f3e4fdcf32
15 changed files with 395 additions and 38 deletions
+7
View File
@@ -2037,6 +2037,13 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
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.',
+41 -1
View File
@@ -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');
});
});
+39 -10
View File
@@ -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) {