From 502905e093b203660ae6d18a21a86cfc39b8ce99 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 31 Aug 2026 09:27:31 +0100 Subject: [PATCH] feat(fortnox): make scope-approval flags overridable via env for self-hosted apps (#2069) FORTNOX_DOCUMENT_SCOPES_APPROVED and FORTNOX_ASSET_SCOPES_APPROVED read env vars of the same name with the hosted registration's values as defaults, so a self-hosted Fortnox app avoids invalid_scope at authorize and the impossible reconnect loop without patching source. Supersedes #2001. Co-authored-by: Pierre Gronberg --- .env.example | 20 +++++-- lib/providers/fortnox/__tests__/oauth.test.ts | 16 ++++++ lib/providers/fortnox/oauth.ts | 55 ++++++++++++++----- 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index feaf106c..08d773a3 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,12 @@ RECEIPT_HUNT_COMPANY_IDS= # Hosted keeps this unset: public signup stays open there. # AUTH_SIGNUPS_DISABLED=false +# Sign in with Google. Requires the Google provider to be configured in +# Supabase/GoTrue first (Google Cloud OAuth client + redirect URI): +# https://supabase.com/docs/guides/auth/social-login/auth-google +# The button stays hidden until this is true. +# NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=true + # Cloudflare Turnstile site key for Supabase Auth bot protection. This value is # public and is embedded in the browser bundle. Leave it unset until a widget # has been created for the deployment's exact hostnames. Deploy the site key @@ -68,12 +74,6 @@ RECEIPT_HUNT_COMPANY_IDS= # existing login flow remains available throughout rollout. # NEXT_PUBLIC_TURNSTILE_SITE_KEY= -# SAML SSO login (Enterprise). When enabled in Supabase GoTrue (saml_enabled), -# the login page shows a SAML button. At least one of these is required to -# tell Supabase which identity provider to redirect to: -# NEXT_PUBLIC_SSO_DOMAIN=your-domain.okta.com # discovers the provider by IdP domain -# NEXT_PUBLIC_SSO_PROVIDER_ID= # explicit provider uuid (overrides domain) - # ── Optional: extension features (core runs without these) ─ # AI features (document extraction + AI assistant). Three ways to provide a # backend; set one of them. AI_PROVIDER (bedrock|anthropic|openai-compatible) @@ -151,6 +151,14 @@ RECEIPT_HUNT_COMPANY_IDS= # FORTNOX_CLIENT_ID= # FORTNOX_CLIENT_SECRET= # FORTNOX_REDIRECT_URI= +# Self-hosted only: which scopes YOUR Fortnox app registration carries in the +# Fortnox Developer Portal. Unset means the hosted deployment's defaults +# (documents true, assets false), which describe the hosted app, not yours. +# Set to the registration's actual state: claiming an unapproved scope makes +# Fortnox reject authorize with invalid_scope before login, and denying an +# approved one just leaves that import feature off. +# FORTNOX_DOCUMENT_SCOPES_APPROVED=true # Arkivplats + Koppla filer (underlag import) +# FORTNOX_ASSET_SCOPES_APPROVED=false # Anlaggningsregister (asset register import) # Björn Lundén app credentials (OAuth2 client credentials; per-company # User-Key is entered by the user in the migration wizard) # BJORN_LUNDEN_CLIENT_ID= diff --git a/lib/providers/fortnox/__tests__/oauth.test.ts b/lib/providers/fortnox/__tests__/oauth.test.ts index 7da28aa9..efd67094 100644 --- a/lib/providers/fortnox/__tests__/oauth.test.ts +++ b/lib/providers/fortnox/__tests__/oauth.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { buildFortnoxAuthUrl, fortnoxConsentScopes, + fortnoxScopeFlag, FORTNOX_DOCUMENT_SCOPES, FORTNOX_DOCUMENT_SCOPES_APPROVED, FORTNOX_ASSET_SCOPES, @@ -49,6 +50,21 @@ describe('Fortnox OAuth scopes', () => { expect(fortnoxConsentScopes()).not.toContain('connectfile'); }); + // The env override exists for self-hosted deployments running their own + // Fortnox app, whose portal registration differs from hosted's. Unset (or + // empty, which is how a commented-out .env line arrives) means the hosted + // default; anything but the string "true" is false, so a typo fails toward + // not requesting a scope rather than toward invalid_scope at authorize. + it('lets env override the hosted scope-approval defaults', () => { + expect(fortnoxScopeFlag(undefined, true)).toBe(true); + expect(fortnoxScopeFlag(undefined, false)).toBe(false); + expect(fortnoxScopeFlag('', true)).toBe(true); + expect(fortnoxScopeFlag('true', false)).toBe(true); + expect(fortnoxScopeFlag(' true ', false)).toBe(true); + expect(fortnoxScopeFlag('false', true)).toBe(false); + expect(fortnoxScopeFlag('yes', true)).toBe(false); + }); + // The asset register scope is gated on its own portal approval. While the // flag is false, no consent may request it: an unapproved scope in the // authorize request is rejected with invalid_scope BEFORE login (the same diff --git a/lib/providers/fortnox/oauth.ts b/lib/providers/fortnox/oauth.ts index 8ed15aa9..19e1aea1 100644 --- a/lib/providers/fortnox/oauth.ts +++ b/lib/providers/fortnox/oauth.ts @@ -19,31 +19,55 @@ const BASE_SCOPES = [ 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). True since 2026-08-21, when - * the portal registration was confirmed to carry both. + * Scope-approval flags describe the FORTNOX APP REGISTRATION, not the code: + * whether the app in the Fortnox Developer Portal carries a given scope. + * Requesting a scope the registration lacks makes the authorize endpoint + * reject with invalid_scope BEFORE login (prod incident 2026-08-13, when the + * ordinary connect carried unapproved scopes and every Fortnox connection + * died), and claiming a scope the connect never asks for sends users into a + * reconnect loop that cannot succeed (support case Klura AB, 2026-08-20). * - * Requesting a scope the app lacks makes the authorize endpoint reject with - * invalid_scope BEFORE login, so set this back to false the moment the portal + * The defaults below describe the hosted deployment's registration + * (integration 39254). A self-hosted deployment runs its OWN Fortnox app + * (FORTNOX_CLIENT_ID in .env), whose registration will differ, so each flag + * can be overridden with an env var of the same name: "true" or "false", + * unset means the hosted default. Without the override, self-hosters whose + * registration differs from hosted's would have to patch this file. + */ +export function fortnoxScopeFlag( + envValue: string | undefined, + hostedDefault: boolean, +): boolean { + // Trimmed: a stray space in a hand-edited .env line must not silently + // flip a scope off and read as a missing feature. + const value = envValue?.trim(); + if (value === undefined || value === '') return hostedDefault; + return value === 'true'; +} + +/** + * Whether the registered Fortnox app has Arkivplats and Koppla filer enabled. + * Hosted default true since 2026-08-21, when the portal registration was + * confirmed to carry both; set the env var to false the moment a registration * loses them, rather than leaving the underlag reconnect pointed at a scope - * Fortnox will refuse (prod incident 2026-08-13, when the ordinary connect - * still carried these scopes and every Fortnox connection died). + * Fortnox will refuse. * * 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). + * permission we don't ask for. */ -export const FORTNOX_DOCUMENT_SCOPES_APPROVED: boolean = true; +export const FORTNOX_DOCUMENT_SCOPES_APPROVED: boolean = fortnoxScopeFlag( + process.env.FORTNOX_DOCUMENT_SCOPES_APPROVED, + true, +); /** The asset register (anläggningsregistret): what the asset import reads. */ export const FORTNOX_ASSET_SCOPES = ['assets']; /** * Whether the registered Fortnox app has the Assets scope (Anläggningsregister) - * enabled in the Fortnox Developer Portal. Ships false until the portal - * registration is confirmed to carry it: requesting a scope the app lacks - * makes the authorize endpoint reject with invalid_scope BEFORE login, the - * same failure mode the document scopes guard against above. + * enabled. Hosted default false until the portal registration is confirmed to + * carry it. * * When true, the ordinary connect requests the scope. Unlike Arkivplats and * Koppla filer, the asset register carries no separate Fortnox customer @@ -51,7 +75,10 @@ export const FORTNOX_ASSET_SCOPES = ['assets']; * scope degrades gracefully: the migration reports assets as skipped instead * of failing (see arcim-migration import-assets). */ -export const FORTNOX_ASSET_SCOPES_APPROVED: boolean = false; +export const FORTNOX_ASSET_SCOPES_APPROVED: boolean = fortnoxScopeFlag( + process.env.FORTNOX_ASSET_SCOPES_APPROVED, + false, +); /** * The scopes a Fortnox consent is minted with. The document scopes are opt-in