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
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 8aa5d54510
commit a769bc9d03
13 changed files with 570 additions and 45 deletions
@@ -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"