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 <pierre@hbgdesignlab.se>
This commit is contained in:
co-authored by
Pierre Gronberg
parent
d67879b786
commit
502905e093
+14
-6
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user