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:
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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=');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user