feat(migration): Björn Lundén activation through Lundify's redirect flow (#2374)

* feat(migration): Björn Lundén activation through Lundify's redirect flow

BL issued our integration activation key on 2026-09-07. With
BJORN_LUNDEN_ACTIVATION_KEY set, the connect step offers "Aktivera i
Lundify": the customer logs in at Lundify, picks the company and accepts
the scopes, and Lundify returns the company's User-Key to our callback as
publicKey with our one-time state echoed as extra. The manual User-Key
field stays as a folded fallback for companies that activated inside
Lundify already.

The callback folds publicKey/extra into the OAuth-shaped locals, so the
atomic state consumption, initiator binding and white-label handoff run
unchanged; only the final step differs: submitProviderToken (the same
client-credentials probe as the manual field) instead of an OAuth code
exchange, owned by the consent's company read from the server-written row.
consumeOAuthState/consumeHandoff now return that company id.

Closes #2323.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L

* fix(migration): reset the previous connect attempt before a new provider request

Review follow-up: a failed /connect used to leave the earlier consent id
and one-time activation URL in place, so the step kept offering a link
that completed the previous consent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L

---------

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-07 15:38:07 +02:00
committed by GitHub
parent 8aa5d54510
commit a769bc9d03
13 changed files with 570 additions and 45 deletions
+4
View File
@@ -206,6 +206,10 @@ GOOGLE_MAIL_CONNECT_COMPANY_IDS=
# User-Key is entered by the user in the migration wizard)
# BJORN_LUNDEN_CLIENT_ID=
# BJORN_LUNDEN_CLIENT_SECRET=
# Integration activation key issued by BL for the service provider. When set,
# the wizard offers "Aktivera i Lundify" (redirect flow that returns the
# User-Key itself); when unset only the manual User-Key field is shown.
# BJORN_LUNDEN_ACTIVATION_KEY=
# WhatsApp receipt intake (whatsapp-inbox extension, Meta Cloud API).
# ACCESS_TOKEN: system-user permanent token with whatsapp_business_messaging
# scope only. PHONE_NUMBER_ID: the Graph object id of the sending number.
+1
View File
@@ -1636,6 +1636,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-06] Bundled SKV ROT/RUT payout books ONE voucher (one 1513 leg per begäran) and the set is suggested at read time with no hint column: one bank row = one verifikat (match-batch precedent) and a uuid[] hint would need six clear paths and go stale; N vouchers + the 1:N reconciliation split was rejected because its half-failure state has no UI exit, and begäran, not the invoice, is the unit under fakturamodellen.
[2026-09-06] Utlägg via lön settles claims with an idempotent RPC after the salary verifikat is posted (pre-checked before posting), not with a trigger on salary_runs -> booked: a raise inside that trigger after the entries exist would leave a paid run with posted verifikat and a retry would double-post; the RPC path fails to "booked, claims still open, re-runnable".
[2026-09-06] A privately paid supplier invoice is booked through registerExpenseClaim (verifikat + expense_claims row, source_type expense_claim) with the invoice's kontering as custom lines, and a person-paid inbox document goes to the core route with inbox_item_id instead of the extension's convert endpoint: the form's switch, the second entry generator and the convert bypass were three write paths for one fact, so one writer wins over adding a claims insert beside the old generator (the issue's shape) or copying the branch into the convert handler.
[2026-09-07] Björn Lundén connect: Lundify activation redirect reuses the OAuth state row (provider_otc) and the callback, with the User-Key arriving as publicKey and the state as extra; the manual User-Key field stays as a folded fallback: BL issued the activation key on 2026-09-07, the redirect removes the GUID copy-paste that failed every real customer, and reusing the atomic state consumption plus initiator binding keeps the same tenant boundary as Fortnox/Visma instead of a second callback with its own checks. BL's ON/OFF backend callback and settings URL are not built: they need a registered URL at BL and an orgNr-to-company mapping, filed as follow-up.
[2026-09-07] Auth-link hosts resolve against the brands table only; NEXT_PUBLIC_WHITELABEL_DOMAINS removed (supersedes 2026-08-18): the env var was a second copy of brands.domain compiled into the browser, so every brand needed four registrations (row, env var, GoTrue allowlist, redeploy) and two partners shipped with it stale (canonical-branded reset mails). Password reset moved to POST /api/auth/password-reset so the server resolves the host; invite, email change and signup share the same resolver, which also trusts this deployment's own VERCEL_URL/VERCEL_BRANCH_URL so previews keep working. A drift check between the copies was rejected: it would be a fifth thing to maintain. GoTrue's redirect allowlist stays as the backstop; hosted carries the wildcards https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/** there (config, not code; GoTrue matches the full URL with query, and * stops at . and /) so only bring-your-own-domain partners need a manual entry. A failed brands lookup refuses with 503 (BrandLookupFailedError) instead of a canonical fallback: a canonical link is a wrong-brand mail for a white-label user, which is the bug this replaces.
[2026-09-07] Draft stamp moved to the page margin (absolute + fixed) instead of the reporter's position:fixed corner badge: the 40pt top margin is the only place that is guaranteed empty on every page, and the stamp must not overlap the header title on the right.
[2026-09-07] Hyphenation disabled per Text node in the invoice template, not via a global Font.registerHyphenationCallback: the global hook would also change line breaking in årsredovisning, payslips and every report PDF; that is a separate decision.
@@ -690,6 +690,7 @@ function ConnectStep({
isLoading,
error,
authUrl,
activationUrl,
consentId,
onTokenSubmit,
onBack,
@@ -699,6 +700,8 @@ function ConnectStep({
isLoading: boolean
error: string | null
authUrl: string | null
/** Björn Lundén only: Lundify's activation redirect, when BL issued us a key. */
activationUrl: string | null
consentId: string | null
onTokenSubmit: (apiToken: string, companyId: string) => void
onBack: () => void
@@ -707,9 +710,33 @@ function ConnectStep({
const providerName = ARCIM_PROVIDERS.find(p => p.id === provider)?.name ?? provider
const [apiToken, setApiToken] = useState('')
const [companyId, setCompanyId] = useState('')
// With the Lundify redirect on offer, the User-Key field is the fallback for
// a customer who activated inside Lundify already, so it starts folded.
const [showManualKey, setShowManualKey] = useState(false)
// BL uses server-side client credentials: only needs company ID, no API key
const isClientCredentials = provider === 'bjornlunden'
const hasLundifyActivation = isClientCredentials && !!activationUrl
const manualKeyVisible = !hasLundifyActivation || showManualKey
const openProviderWindow = (url: string) => {
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
const popup = window.open(url, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
if (!popup) {
// Popup blocked: with the return value discarded, a blocked
// popup looked exactly like a successful one (nothing opens,
// nothing is said, the user clicks again). Fall back to the
// full-page flow instead. The callback already supports it:
// with no window.opener it redirects to
// /import?migration=connected&consentId=..., which
// handleOAuthReturn consumes and resumes the wizard at the
// preview step. Same treatment as SkatteverketConnectPanel.
window.location.href = url
}
}
// WINT has no API keys: the "token" is the user's WINT login (e-post +
// lösenord), exchanged server-side for ett tokenpar; lösenordet sparas aldrig.
const isWintLogin = provider === 'wint'
@@ -727,7 +754,9 @@ function ConnectStep({
? t('ext_arcim_bokio_company_id_label')
: 'Företags-ID'
const tokenDescription = isClientCredentials
const tokenDescription = hasLundifyActivation
? t('ext_arcim_bl_activate_description', { appName: branding.appName })
: isClientCredentials
? t('ext_arcim_bl_token_description', { appName: branding.appName })
: isWintLogin
? `Logga in med dina WINT-uppgifter för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata. Lösenordet används en gång för att skapa anslutningen och sparas aldrig.`
@@ -786,35 +815,36 @@ function ConnectStep({
Klicka nedan för att logga in i {providerName}.
Fönstret stängs automatiskt när du är klar.
</p>
<Button
className="min-h-11"
onClick={() => {
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
const popup = window.open(authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
if (!popup) {
// Popup blocked: with the return value discarded, a blocked
// popup looked exactly like a successful one (nothing opens,
// nothing is said, the user clicks again). Fall back to the
// full-page flow instead. The callback already supports it:
// with no window.opener it redirects to
// /import?migration=connected&consentId=..., which
// handleOAuthReturn consumes and resumes the wizard at the
// preview step. Same treatment as SkatteverketConnectPanel.
window.location.href = authUrl
}
}}
>
<Button className="min-h-11" onClick={() => openProviderWindow(authUrl)}>
Logga in i {providerName}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
</div>
)}
{/* Björn Lundén: Lundify's activation redirect returns the User-Key
itself. The popup posts arcim-oauth-success like the OAuth
providers, so the same listener resumes the wizard. */}
{authType === 'token' && consentId && !isLoading && hasLundifyActivation && activationUrl && (
<div className="space-y-4">
<Button className="min-h-11" onClick={() => openProviderWindow(activationUrl)}>
{t('ext_arcim_bl_activate_button')}
<ExternalLink className="ml-2 h-4 w-4" />
</Button>
{!showManualKey && (
<Button
variant="link"
className="h-auto px-0 text-sm text-muted-foreground"
onClick={() => setShowManualKey(true)}
>
{t('ext_arcim_bl_manual_key_toggle')}
</Button>
)}
</div>
)}
{/* Token-based flow */}
{authType === 'token' && consentId && !isLoading && (
{authType === 'token' && consentId && !isLoading && manualKeyVisible && (
<div className="max-w-md space-y-4">
<p className="text-sm text-muted-foreground">
{tokenHelpText}
@@ -2278,6 +2308,9 @@ export default function ArcimMigrationWorkspace({
const [selectedProvider, setSelectedProvider] = useState<ArcimProvider | null>(null)
const [consentId, setConsentId] = useState<string | null>(null)
const [authUrl, setAuthUrl] = useState<string | null>(null)
// Björn Lundén: Lundify activation URL from /connect (null when BL has not
// issued an activation key, in which case only the User-Key field shows).
const [activationUrl, setActivationUrl] = useState<string | null>(null)
const [authType, setAuthType] = useState<'oauth' | 'token' | null>(null)
// Preview state
@@ -2412,6 +2445,13 @@ export default function ArcimMigrationWorkspace({
setStep('connect')
setIsLoading(true)
setError(null)
// Drop the previous attempt's consent and one-time URLs before asking for
// new ones: if /connect fails, the step must not keep offering a stale
// activation link that completes the earlier consent.
setConsentId(null)
setAuthType(null)
setAuthUrl(null)
setActivationUrl(null)
try {
const res = await fetch('/api/extensions/ext/arcim-migration/connect', {
@@ -2428,6 +2468,7 @@ export default function ArcimMigrationWorkspace({
const data = await res.json()
setConsentId(data.consentId)
setAuthType(data.authType)
setActivationUrl(typeof data.activationUrl === 'string' ? data.activationUrl : null)
if (data.alreadyConnected) {
// Existing connection: skip auth, go straight to preview
@@ -2556,6 +2597,7 @@ export default function ArcimMigrationWorkspace({
popup?.close()
if (data.authType === 'token') {
// Re-enter credentials for token-based providers
setActivationUrl(typeof data.activationUrl === 'string' ? data.activationUrl : null)
setStep('connect')
}
}
@@ -3203,6 +3245,7 @@ export default function ArcimMigrationWorkspace({
isLoading={isLoading}
error={error}
authUrl={authUrl}
activationUrl={activationUrl}
consentId={consentId}
onTokenSubmit={handleTokenSubmit}
onBack={() => {
@@ -0,0 +1,274 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { createMockRequest } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
import { getErrorEntry } from '@/lib/errors/structured-errors'
/**
* Björn Lundén through Lundify's activation redirect (issue #2323).
*
* BL sends the customer back to our callback as
* `?publicKey={User-Key}&extra={state}` instead of OAuth's code/state. The
* callback folds that into the same server-written state row the OAuth
* providers use, then runs the User-Key through the SAME probe-then-store
* path as the manual field (submitProviderToken), owned by the consent's own
* company. These tests pin that nothing about the state handling loosened for
* BL, and that the activation URL /connect hands out follows BL's format.
*/
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(),
mintHandoff: vi.fn(),
consumeHandoff: 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 {
constructor(message: string, public readonly kind: string = 'credentials') {
super(message)
}
},
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/branding/resolve', () => ({ resolveBrandByHost: vi.fn().mockResolvedValue(null) }))
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,
submitProviderToken,
createConsent,
listConsents,
generateOtc,
getAuthUrl,
ProviderTokenInvalidError,
} 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 CALLBACK_PATH = '/api/extensions/ext/arcim-migration/callback'
const ACTIVATION_KEY = '36b2bf61-0514-4825-a8a5-08cf151176f2'
const USER_KEY = '1f0e2d3c-4b5a-4c6d-8e7f-0a1b2c3d4e5f'
const blState = {
consentId: 'consent-bl',
provider: 'bjornlunden',
companyId: 'company-1',
userId: 'user-1',
origin: APP_URL,
} as const
const ctxFor = (userId: string) => ({
companyId: 'company-1',
supabase: { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: userId } } }) } },
}) as unknown as ExtensionContext
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(() => {})
vi.mocked(requireFlowInitiator).mockResolvedValue({ ok: true, userId: 'user-1' })
vi.mocked(consumeOAuthState).mockResolvedValue(blState)
vi.mocked(submitProviderToken).mockResolvedValue({ success: true, consentId: 'consent-bl' })
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
describe('POST /connect for Björn Lundén', () => {
beforeEach(() => {
vi.mocked(listConsents).mockResolvedValue([])
vi.mocked(createConsent).mockResolvedValue({ id: 'consent-bl' } as Awaited<ReturnType<typeof createConsent>>)
vi.mocked(generateOtc).mockResolvedValue({ code: 'otc-code', consentId: 'consent-bl', expiresAt: '' })
})
const connect = (body: Record<string, unknown>) =>
connectHandler(
createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/connect`, { method: 'POST', body }),
ctxFor('user-1'),
)
it('hands out a Lundify activation URL bound to a fresh state row when BL issued a key', async () => {
vi.stubEnv('BJORN_LUNDEN_ACTIVATION_KEY', ACTIVATION_KEY)
const response = await connect({ provider: 'bjornlunden' })
const body = await response.json()
expect(response.status).toBe(200)
expect(body.authType).toBe('token')
expect(body.consentId).toBe('consent-bl')
// The state is minted for THIS consent and user, like the OAuth providers.
expect(generateOtc).toHaveBeenCalledWith('consent-bl', 'user-1', APP_URL)
expect(body.activationUrl).toBe(
`https://lundify.com/activate-integration/${ACTIVATION_KEY}/${encodeURIComponent(`${APP_URL}${CALLBACK_PATH}`)}?extra=otc-code`,
)
// Not an OAuth provider: no authorization URL is built.
expect(getAuthUrl).not.toHaveBeenCalled()
})
it('keeps the manual User-Key path only when no activation key is configured', async () => {
vi.stubEnv('BJORN_LUNDEN_ACTIVATION_KEY', '')
const body = await (await connect({ provider: 'bjornlunden' })).json()
expect(body.authType).toBe('token')
expect(body).not.toHaveProperty('activationUrl')
expect(generateOtc).not.toHaveBeenCalled()
})
it('offers the activation URL on reconnect against the stale consent', async () => {
vi.stubEnv('BJORN_LUNDEN_ACTIVATION_KEY', ACTIVATION_KEY)
vi.mocked(listConsents).mockResolvedValue([
{ id: 'consent-stale', provider: 'bjornlunden', status: 1 },
] as Awaited<ReturnType<typeof listConsents>>)
vi.mocked(generateOtc).mockResolvedValue({ code: 'otc-2', consentId: 'consent-stale', expiresAt: '' })
const body = await (await connect({ provider: 'bjornlunden', reconnect: true })).json()
expect(body).toMatchObject({ consentId: 'consent-stale', authType: 'token', reconnect: true })
expect(generateOtc).toHaveBeenCalledWith('consent-stale', 'user-1', APP_URL)
expect(body.activationUrl).toContain('?extra=otc-2')
expect(createConsent).not.toHaveBeenCalled()
})
it('does not hand other token providers an activation URL', async () => {
vi.stubEnv('BJORN_LUNDEN_ACTIVATION_KEY', ACTIVATION_KEY)
const body = await (await connect({ provider: 'bokio' })).json()
expect(body.authType).toBe('token')
expect(body).not.toHaveProperty('activationUrl')
expect(generateOtc).not.toHaveBeenCalled()
})
})
describe('GET /callback from Lundify (publicKey + extra)', () => {
const request = (params: Record<string, string>) =>
createMockRequest(`${APP_URL}${CALLBACK_PATH}`, { searchParams: params })
it('resolves the consent from the state row and stores the User-Key through submitProviderToken', async () => {
const req = request({ publicKey: USER_KEY, extra: 'otc-code' })
const response = await callbackHandler(req)
const html = await response.text()
expect(response.status).toBe(200)
// `extra` IS the state: the same atomic consumption as OAuth's `state`.
expect(consumeOAuthState).toHaveBeenCalledWith('otc-code')
// The completing session must be the initiator, exactly like OAuth.
expect(requireFlowInitiator).toHaveBeenCalledWith(req, 'user-1', { flow: 'arcim-migration.callback' })
// Same probe-then-store as the manual field; owner = the consent's company
// read server-side, never anything from the query string.
expect(submitProviderToken).toHaveBeenCalledWith(
'consent-bl',
'bjornlunden',
'client_credentials',
USER_KEY,
'company-1',
)
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(html).toContain('arcim-oauth-success')
expect(html).toContain(`${APP_URL}/import?migration=connected&consentId=consent-bl`)
})
it('shows the registry sentence when the company has not activated the integration', async () => {
vi.mocked(submitProviderToken).mockRejectedValue(
new ProviderTokenInvalidError('no scopes', 'integration-not-activated'),
)
const html = await (await callbackHandler(request({ publicKey: USER_KEY, extra: 'otc-code' }))).text()
expect(html).toContain('arcim-oauth-error')
expect(html).toContain(getErrorEntry('BL_INTEGRATION_NOT_ACTIVATED')!.message_sv)
expect(html).not.toContain('no scopes')
})
it('shows the registry sentence when BL knows no company for the key', async () => {
vi.mocked(submitProviderToken).mockRejectedValue(
new ProviderTokenInvalidError('HTTP 500', 'company-key-not-found'),
)
const html = await (await callbackHandler(request({ publicKey: USER_KEY, extra: 'otc-code' }))).text()
expect(html).toContain(getErrorEntry('BL_COMPANY_KEY_NOT_FOUND')!.message_sv)
})
it('rejects a publicKey without our state before touching any row', async () => {
const html = await (await callbackHandler(request({ publicKey: USER_KEY }))).text()
expect(html).toContain('saknade code eller state')
expect(consumeOAuthState).not.toHaveBeenCalled()
expect(submitProviderToken).not.toHaveBeenCalled()
})
it('rejects a forged or replayed extra with the generic message and stores nothing', async () => {
vi.mocked(consumeOAuthState).mockResolvedValue(null)
const html = await (await callbackHandler(request({ publicKey: USER_KEY, extra: 'forged' }))).text()
expect(html).toContain('Ingen giltig migrationssession hittades')
expect(submitProviderToken).not.toHaveBeenCalled()
expect(requireFlowInitiator).not.toHaveBeenCalled()
})
it('refuses a completing session that is not the initiator', async () => {
vi.mocked(requireFlowInitiator).mockResolvedValue({
ok: false,
reason: 'mismatch',
response: new Response(null, { status: 403 }),
sessionUserId: 'other-user',
})
const html = await (await callbackHandler(request({ publicKey: USER_KEY, extra: 'otc-code' }))).text()
expect(html).toContain('initiator mismatch')
expect(submitProviderToken).not.toHaveBeenCalled()
})
it('does not let a publicKey override an OAuth code on the same request', async () => {
vi.mocked(consumeOAuthState).mockResolvedValue({ ...blState, provider: 'fortnox' })
await callbackHandler(request({ code: 'oauth-code', state: 'otc-code', publicKey: USER_KEY, extra: 'other' }))
expect(consumeOAuthState).toHaveBeenCalledWith('otc-code')
expect(exchangeAuthToken).toHaveBeenCalledWith('consent-bl', 'fortnox', 'oauth-code', `${APP_URL}${CALLBACK_PATH}`)
expect(submitProviderToken).not.toHaveBeenCalled()
})
})
@@ -118,7 +118,7 @@ function forgedLegacyState(consentId: string, provider: string) {
describe('white-label OAuth callback handoff', () => {
const BRAND_ORIGIN = 'https://solbo.accounted.se'
const path = '/api/extensions/ext/arcim-migration/callback'
const state = { consentId: 'consent-1', provider: 'fortnox', userId: 'user-1', origin: BRAND_ORIGIN } as const
const state = { consentId: 'consent-1', provider: 'fortnox', companyId: 'company-1', userId: 'user-1', origin: BRAND_ORIGIN } as const
const request = (origin: string, params: Record<string, string>) =>
createMockRequest(`${origin}${path}`, { searchParams: params })
const storedHandoff = { ...state, providerCode: 'stored-code', providerError: null }
@@ -85,7 +85,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', user_id: 'user-1' } },
{ data: { provider: 'fortnox' } },
{ data: { provider: 'fortnox', company_id: 'company-1' } },
])
await consumeOAuthState('state-token')
@@ -106,11 +106,12 @@ describe('consumeOAuthState', () => {
})
it('returns the consent and the provider read from the server-side rows', async () => {
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'visma' } }])
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'visma', company_id: 'company-1' } }])
await expect(consumeOAuthState('state-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'visma',
companyId: 'company-1',
userId: 'user-1',
origin: null,
})
@@ -119,7 +120,7 @@ describe('consumeOAuthState', () => {
it('reads the provider from provider_consents, never from the caller', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1', user_id: 'user-1' } },
{ data: { provider: 'fortnox' } },
{ data: { provider: 'fortnox', company_id: 'company-1' } },
])
await consumeOAuthState('state-token')
@@ -143,10 +144,11 @@ describe('consumeOAuthState', () => {
})
it('returns null on replay: the second consume of the same token loses', async () => {
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'fortnox' } }])
useResults([{ data: { consent_id: 'consent-1', user_id: 'user-1' } }, { data: { provider: 'fortnox', company_id: 'company-1' } }])
await expect(consumeOAuthState('one-time-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'fortnox',
companyId: 'company-1',
userId: 'user-1',
origin: null,
})
@@ -159,11 +161,12 @@ describe('consumeOAuthState', () => {
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' } }])
useResults([{ data: { consent_id: 'consent-1', user_id: null } }, { data: { provider: 'fortnox', company_id: 'company-1' } }])
await expect(consumeOAuthState('state-token')).resolves.toEqual({
consentId: 'consent-1',
provider: 'fortnox',
companyId: 'company-1',
userId: null,
origin: null,
})
@@ -240,7 +243,7 @@ describe('OAuth handoff storage', () => {
provider_error: result.providerError ? expect.stringMatching(/^v1:/) : null,
})
expect(JSON.stringify(inserted)).not.toContain(result.providerCode ?? result.providerError)
useResults([{ data: inserted }, { data: { provider: 'fortnox' } }])
useResults([{ data: inserted }, { data: { provider: 'fortnox', company_id: 'company-1' } }])
await expect(consumeHandoff(first.code, origin)).resolves.toMatchObject({
providerCode: result.providerCode ?? null, providerError: result.providerError ?? null,
})
@@ -253,10 +256,10 @@ describe('OAuth handoff storage', () => {
consent_id: 'consent-1', user_id: 'user-1', origin, provider_error: null,
provider_code: encryptHandoffValue('stored-code', JSON.stringify(['handoff-token', 'consent-1', 'user-1', origin, 'provider_code'])),
} },
{ data: { provider: 'visma' } },
{ data: { provider: 'visma', company_id: 'company-1' } },
])
await expect(consumeHandoff('handoff-token', origin)).resolves.toEqual({
consentId: 'consent-1', userId: 'user-1', origin, provider: 'visma',
consentId: 'consent-1', userId: 'user-1', origin, provider: 'visma', companyId: 'company-1',
providerCode: 'stored-code', providerError: null,
})
expect(calls[0].ops[0][0]).toBe('delete')
@@ -291,7 +294,7 @@ describe('OAuth handoff storage', () => {
it('rejects unencrypted or corrupted handoff credentials after deleting the row', async () => {
const { calls } = useResults([
{ data: { consent_id: 'consent-1', user_id: 'user-1', origin, provider_code: 'plaintext-code', provider_error: null } },
{ data: { provider: 'fortnox' } },
{ data: { provider: 'fortnox', company_id: 'company-1' } },
])
await expect(consumeHandoff('token', origin)).resolves.toBeNull()
expect(calls[0].ops[0][0]).toBe('delete')
+80 -7
View File
@@ -46,6 +46,10 @@ import { loadMappings, generateImportPreview, executeSIEImport, findOverlappingP
import { buildMappingTargets } from './lib/mapping-targets'
import type { ProviderName } from '@/lib/providers/types'
import { FORTNOX_DOCUMENT_SCOPES_APPROVED } from '@/lib/providers/fortnox/oauth'
import {
buildLundifyActivationUrl,
getBjornLundenActivationKey,
} from '@/lib/providers/bjornlunden/activation'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import {
@@ -198,6 +202,26 @@ async function buildArcimOAuthUrl(
return url
}
/**
* Lundify activation URL for a Björn Lundén consent, or null when BL has not
* issued this install an activation key (self-hosted, or a listing that is
* not released). Same state row as the OAuth providers: Lundify echoes the
* code back as `extra`, and the callback resolves the consent from that row
* exactly as it resolves an OAuth `state`. The manual User-Key field stays
* next to the button, so a customer who activated inside Lundify already can
* still paste the key.
*/
async function buildBjornLundenActivationUrl(
consentId: string,
initiatedByUserId: string,
origin: string,
): Promise<string | null> {
const activationKey = getBjornLundenActivationKey()
if (!activationKey) return null
const otc = await generateOtc(consentId, initiatedByUserId, origin)
return buildLundifyActivationUrl(activationKey, resolveArcimCallbackUrl('bjornlunden'), otc.code)
}
/**
* Answer a failed provider call with its classified code, falling back to the
* caller's own code when the failure is not provider-shaped.
@@ -410,10 +434,15 @@ export const arcimMigrationExtension: Extension = {
})
}
// Token-based providers re-authorize by re-entering credentials
// (Björn Lundén also through Lundify's activation redirect).
const activationUrl = provider === 'bjornlunden'
? await buildBjornLundenActivationUrl(stale.id, user.id, await resolveOAuthOrigin(request))
: null
return NextResponse.json({
consentId: stale.id,
authType: 'token',
reconnect: true,
...(activationUrl ? { activationUrl } : {}),
})
}
// No existing consent to revive: fall through to a normal connect.
@@ -492,10 +521,16 @@ export const arcimMigrationExtension: Extension = {
authUrl,
})
} else {
// Token-based providers: consent is ready for direct use
// Token-based providers: consent is ready for direct use. Björn
// Lundén additionally gets the Lundify activation URL when BL has
// issued an activation key, so the User-Key never has to be pasted.
const activationUrl = provider === 'bjornlunden'
? await buildBjornLundenActivationUrl(consent.id, user.id, await resolveOAuthOrigin(request))
: null
return NextResponse.json({
consentId: consent.id,
authType: 'token',
...(activationUrl ? { activationUrl } : {}),
})
}
} catch (error) {
@@ -625,7 +660,18 @@ export const arcimMigrationExtension: Extension = {
const url = new URL(request.url)
let code = url.searchParams.get('code')
const handoff = url.searchParams.get('handoff')
const stateRaw = url.searchParams.get('state')
let stateRaw = url.searchParams.get('state')
// Lundify's activation redirect (Björn Lundén) comes back as
// `?publicKey={User-Key}&extra={our state}` instead of code/state.
// Fold it into the OAuth-shaped locals so the atomic state
// consumption, initiator binding and white-label handoff below run
// unchanged; only the final exchange step differs.
const lundifyPublicKey = url.searchParams.get('publicKey')
const lundifyExtra = url.searchParams.get('extra')
if (!code && !handoff && lundifyPublicKey && lundifyExtra) {
code = lundifyPublicKey
stateRaw = lundifyExtra
}
const oauthError = url.searchParams.get('error')
const oauthErrorDescription = url.searchParams.get('error_description')
const currentOrigin = requestOrigin(request)
@@ -792,12 +838,26 @@ export const arcimMigrationExtension: Extension = {
if (providerError !== null) return respondWithError(providerError, consentId)
if (!code) return respondWithError(STATE_REJECTED_MESSAGE)
// Must match the redirect_uri the authorization request was built
// with, so both come from resolveArcimCallbackUrl.
const redirectUri = resolveArcimCallbackUrl(provider)
if (provider === 'bjornlunden') {
// Lundify handed back the company's User-Key. Same probe-then-store
// path as the manual field (client-credentials token, /details
// probe, scope verdict), owned by the consent's own company: the
// consent came from the server-written state row, not the query.
await submitProviderToken(
consentId,
provider,
'client_credentials',
code,
resolvedState.companyId,
)
} else {
// Must match the redirect_uri the authorization request was built
// with, so both come from resolveArcimCallbackUrl.
const redirectUri = resolveArcimCallbackUrl(provider)
// Exchange OAuth code directly with the provider
await exchangeAuthToken(consentId, provider, code, redirectUri)
// Exchange OAuth code directly with the provider
await exchangeAuthToken(consentId, provider, code, redirectUri)
}
// Return an HTML page that notifies the opener tab and closes itself
const successUrl = `${responseOrigin}/import?migration=connected&consentId=${encodeURIComponent(consentId)}`
@@ -837,6 +897,19 @@ export const arcimMigrationExtension: Extension = {
callbackConsentId,
)
}
// Björn Lundén via Lundify: the User-Key probe has the same three
// verdicts as /submit-token, so show the same registry sentences.
if (error instanceof ProviderTokenInvalidError) {
const registryCode = error.kind === 'integration-not-activated'
? 'BL_INTEGRATION_NOT_ACTIVATED'
: error.kind === 'company-key-not-found'
? 'BL_COMPANY_KEY_NOT_FOUND'
: 'PROVIDER_TOKEN_INVALID'
return respondWithError(
getErrorEntry(registryCode)?.message_sv ?? error.message,
callbackConsentId,
)
}
const reason = error instanceof Error ? error.message : 'Okänt fel vid tokenutbyte.'
return respondWithError(reason, callbackConsentId)
}
@@ -302,7 +302,14 @@ export async function generateOtc(
*/
export async function consumeOAuthState(
state: string,
): Promise<{ consentId: string; provider: ProviderName; userId: string | null; origin: string | null } | null> {
): Promise<{
consentId: string
provider: ProviderName
/** The Accounted company that owns the consent (read server-side, never from the callback). */
companyId: string
userId: string | null
origin: string | null
} | null> {
const supabase = createServiceClient()
const now = new Date().toISOString()
@@ -323,17 +330,18 @@ export async function consumeOAuthState(
const { data: consent } = await supabase
.from('provider_consents')
.select('provider')
.select('provider, company_id')
.eq('id', consumed.consent_id)
.maybeSingle()
if (!consent?.provider) {
if (!consent?.provider || !consent.company_id) {
return null
}
return {
consentId: consumed.consent_id as string,
provider: consent.provider as ProviderName,
companyId: consent.company_id as string,
userId: typeof consumed.user_id === 'string' ? consumed.user_id : null,
origin: typeof consumed.origin === 'string' ? consumed.origin : null,
}
@@ -371,6 +379,8 @@ export async function mintHandoff(
export async function consumeHandoff(code: string, origin: string): Promise<{
consentId: string
provider: ProviderName
/** The Accounted company that owns the consent (read server-side, never from the callback). */
companyId: string
userId: string | null
origin: string
providerCode: string | null
@@ -391,10 +401,10 @@ export async function consumeHandoff(code: string, origin: string): Promise<{
const { data: consent } = await supabase
.from('provider_consents')
.select('provider')
.select('provider, company_id')
.eq('id', consumed.consent_id)
.maybeSingle()
if (!consent?.provider) return null
if (!consent?.provider || !consent.company_id) return null
try {
const decrypt = (column: 'provider_code' | 'provider_error') => consumed[column] === null
@@ -405,6 +415,7 @@ export async function consumeHandoff(code: string, origin: string): Promise<{
return {
consentId: consumed.consent_id as string,
provider: consent.provider as ProviderName,
companyId: consent.company_id as string,
userId: typeof consumed.user_id === 'string' ? consumed.user_id : null,
origin: consumed.origin as string,
providerCode: decrypt('provider_code'),
@@ -14,6 +14,7 @@
"VISMA_REDIRECT_URI",
"BJORN_LUNDEN_CLIENT_ID",
"BJORN_LUNDEN_CLIENT_SECRET",
"BJORN_LUNDEN_ACTIVATION_KEY",
"WINT_MIGRATION_ENABLED",
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN"
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
LUNDIFY_ACTIVATION_BASE_URL,
buildLundifyActivationUrl,
getBjornLundenActivationKey,
} from '../activation';
const ORIGINAL_KEY = process.env.BJORN_LUNDEN_ACTIVATION_KEY;
afterEach(() => {
if (ORIGINAL_KEY === undefined) {
delete process.env.BJORN_LUNDEN_ACTIVATION_KEY;
} else {
process.env.BJORN_LUNDEN_ACTIVATION_KEY = ORIGINAL_KEY;
}
});
describe('getBjornLundenActivationKey', () => {
it('returns null when the env var is unset or blank', () => {
delete process.env.BJORN_LUNDEN_ACTIVATION_KEY;
expect(getBjornLundenActivationKey()).toBeNull();
process.env.BJORN_LUNDEN_ACTIVATION_KEY = ' ';
expect(getBjornLundenActivationKey()).toBeNull();
});
it('returns the trimmed key when set', () => {
process.env.BJORN_LUNDEN_ACTIVATION_KEY = ' 36b2bf61-0514-4825-a8a5-08cf151176f2 ';
expect(getBjornLundenActivationKey()).toBe('36b2bf61-0514-4825-a8a5-08cf151176f2');
});
});
describe('buildLundifyActivationUrl', () => {
const KEY = '36b2bf61-0514-4825-a8a5-08cf151176f2';
const CALLBACK = 'https://app.accounted.se/api/extensions/ext/arcim-migration/callback';
it('follows BL: /activate-integration/{key}/{encodedRedirectUrl}?extra={state}', () => {
const url = buildLundifyActivationUrl(KEY, CALLBACK, 'state-abc_123');
expect(url).toBe(
`${LUNDIFY_ACTIVATION_BASE_URL}/${KEY}/${encodeURIComponent(CALLBACK)}?extra=state-abc_123`,
);
});
it('encodes the redirect URL as ONE path segment so its slashes do not split the path', () => {
const url = new URL(buildLundifyActivationUrl(KEY, CALLBACK, 's'));
const segments = url.pathname.split('/').filter(Boolean);
expect(segments).toEqual(['activate-integration', KEY, encodeURIComponent(CALLBACK)]);
expect(decodeURIComponent(segments[2]!)).toBe(CALLBACK);
});
it('percent-encodes a state that carries URL-significant characters', () => {
const state = 'a+b&c=d/e';
const url = new URL(buildLundifyActivationUrl(KEY, CALLBACK, state));
expect(url.searchParams.get('extra')).toBe(state);
expect(url.search).not.toContain('&c=');
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* Björn Lundén activation via Lundify's redirect flow.
*
* BL documents three ways a customer can activate an integration (Company
* Activation & Key Retrieval, developer.bjornlunden.se/2025/03/31/activation-guide/).
* The third one removes the GUID copy-paste from our connect step: we send the
* user to Lundify with our integration activation key, they log in, pick the
* company and accept the scopes, and Lundify sends them back to our callback
* with the company's User-Key as `publicKey` and our opaque state as `extra`.
*
* https://lundify.com/activate-integration/{integrationActivationKey}/{encodedRedirectUrl}?extra={state}
* -> {redirectUrl}?publicKey={userKey}&extra={state}
*
* The activation key is issued by BL once per service provider (ours arrived
* 2026-09-07). It is not a secret in the credential sense: it is embedded in a
* URL the customer's browser visits. It still lives in an env var so
* self-hosted installs without a BL listing simply keep the manual User-Key
* field.
*/
export const LUNDIFY_ACTIVATION_BASE_URL = 'https://lundify.com/activate-integration';
/**
* The activation key BL issued for this service provider, or null when the
* redirect flow is not configured (the connect step then only offers the
* manual User-Key field).
*/
export function getBjornLundenActivationKey(): string | null {
const key = process.env.BJORN_LUNDEN_ACTIVATION_KEY?.trim();
return key ? key : null;
}
/**
* Build the Lundify activation URL for one connect attempt.
*
* `redirectUrl` is our callback (the same one the OAuth providers use) and is
* percent-encoded as a single path segment, which is what BL's
* `{encodedRedirectUrl}` placeholder asks for: an unencoded URL would split on
* its own slashes. `state` is the opaque one-time code minted for the consent;
* Lundify echoes it back untouched as `extra`, so the callback can resolve the
* consent from a server-written row instead of trusting anything in the query.
*/
export function buildLundifyActivationUrl(
activationKey: string,
redirectUrl: string,
state: string,
): string {
const url = new URL(
`${LUNDIFY_ACTIVATION_BASE_URL}/${encodeURIComponent(activationKey)}/${encodeURIComponent(redirectUrl)}`,
);
url.searchParams.set('extra', state);
return url.toString();
}
+3
View File
@@ -5729,6 +5729,9 @@
"ext_arcim_bokio_token_help": "Create or copy your integration token in Bokio under Settings → API Tokens. The company ID is the GUID in the browser address when you view the company overview. The token and company ID must come from the same Bokio company.",
"ext_arcim_bl_token_description": "First activate {appName} under Integrations in Lundify or BL Administration, then enter the company key (User-Key). {appName} connects through Björn Lundén's integration-partner access, so no API key of your own is needed.",
"ext_arcim_bl_token_help": "The company key (User-Key) is a GUID shown at the gear icon for the integration under Integrations in Lundify, or in the activation e-mail from Björn Lundén. The key only works once the integration is activated for the company.",
"ext_arcim_bl_activate_description": "Log in to Lundify, pick the company and allow {appName} to read the bookkeeping. The company key is picked up automatically when you come back.",
"ext_arcim_bl_activate_button": "Activate in Lundify",
"ext_arcim_bl_manual_key_toggle": "Already activated the integration in Lundify? Enter the company key yourself",
"ext_arcim_bokio_token_label": "Integration token",
"ext_arcim_bokio_token_placeholder": "Paste your integration token",
"ext_arcim_bokio_company_id_label": "Company ID",
+3
View File
@@ -5729,6 +5729,9 @@
"ext_arcim_bokio_token_help": "Skapa eller kopiera din integrationstoken i Bokio under Inställningar → API Tokens. Företags-ID:t är det GUID som syns i webbadressen när du visar företagets översikt. Token och företags-ID måste komma från samma Bokio-företag.",
"ext_arcim_bl_token_description": "Aktivera först {appName} under Integrationer i Lundify eller BL Administration, och ange sedan företagets nyckel (User-Key). {appName} ansluter via Björn Lundéns integrationspartner-åtkomst, så ingen egen API-nyckel behövs.",
"ext_arcim_bl_token_help": "Företagsnyckeln (User-Key) är ett GUID som visas vid kugghjulet för integrationen under Integrationer i Lundify, eller i aktiveringsmejlet från Björn Lundén. Nyckeln fungerar bara när integrationen är aktiverad för företaget.",
"ext_arcim_bl_activate_description": "Logga in i Lundify, välj företag och godkänn att {appName} får läsa bokföringen. Företagsnyckeln hämtas automatiskt när du kommer tillbaka.",
"ext_arcim_bl_activate_button": "Aktivera i Lundify",
"ext_arcim_bl_manual_key_toggle": "Har du redan aktiverat integrationen i Lundify? Ange företagsnyckeln själv",
"ext_arcim_bokio_token_label": "Integrationstoken",
"ext_arcim_bokio_token_placeholder": "Klistra in din integrationstoken",
"ext_arcim_bokio_company_id_label": "Företags-ID",