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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
8aa5d54510
commit
a769bc9d03
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user