Files
accounted/extensions/general/arcim-migration/__tests__/oauth-callback-initiator.test.ts
T
Mattsson f7f40c04e4 fix: return provider OAuth callbacks to the initiating brand domain (#2305)
* fix: hand provider OAuth callbacks back to initiating brand

* fix: encrypt provider OAuth handoff payloads

* fix: purge expired provider OAuth handoffs
2026-09-05 13:35:11 +02:00

256 lines
9.2 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
import { createMockRequest } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
/**
* Binds the OAuth callback to the user who STARTED the flow.
*
* oauth-callback-state.test.ts pins that the callback trusts nothing but the
* server-written state row. That row proves the callback belongs to a flow we
* started; it does not say who is finishing it. The authorize URL is
* shareable, so a victim lured into approving a Fortnox/Visma consent that an
* attacker started would have their provider account bound to the attacker's
* consent, and the attacker's next migration would import the victim's ledger.
*
* The state row now records the initiator (provider_otc.user_id, written by
* generateOtc from /connect) and the callback requires the completing
* browser's own session to be that user before the code is exchanged. The
* binding helper is the real one; only the cookie session behind it is faked.
*/
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(),
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 {},
// Mirrors the real constructor and message so the callback's mapping from
// this error to the Swedish registry sentence is exercised for real.
ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {
constructor(
public readonly expectedOrgNumber: string,
public readonly actualOrgNumber: string,
public readonly actualCompanyName: string | null,
) {
super(
`Provider company mismatch: credentials open ${actualOrgNumber}, ` +
`but the target company is ${expectedOrgNumber}`,
)
}
},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
const { mockCreateClient } = vi.hoisted(() => ({ mockCreateClient: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createClient: mockCreateClient,
createServiceClient: vi.fn(),
}))
import { arcimMigrationExtension } from '../index'
import {
consumeOAuthState,
exchangeAuthToken,
generateOtc,
getAuthUrl,
listConsents,
createConsent,
ProviderCompanyMismatchError,
} 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 STATE_REJECTED = 'Ingen giltig migrationssession hittades'
/** The browser completing the callback is signed in as `userId` (or nobody). */
function useSession(userId: string | null) {
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: userId ? { id: userId } : null },
error: null,
}),
},
})
}
function callbackRequest(params: Record<string, string>) {
return createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/callback`, {
searchParams: params,
})
}
function connectCtx(userId = 'user-1'): ExtensionContext {
return {
supabase: { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: userId } } }) } },
companyId: 'company-1',
userId,
} as unknown as ExtensionContext
}
describe('GET /callback: the completing session must be the initiator', () => {
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(() => {})
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: 'user-1',
})
;(exchangeAuthToken as Mock).mockResolvedValue({ success: true, consentId: 'consent-1' })
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('finalises when the browser session belongs to the user who started the flow', async () => {
useSession('user-1')
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).toHaveBeenCalledTimes(1)
expect(exchangeAuthToken).toHaveBeenCalledWith(
'consent-1',
'fortnox',
'provider-auth-code',
`${APP_URL}/api/extensions/ext/arcim-migration/callback`,
)
expect(html).toContain('Anslutningen lyckades')
})
it('refuses a completion by a different signed-in user without exchanging the code', async () => {
// The victim (user-2) was lured into approving user-1's consent.
useSession('user-2')
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(html).toContain('Anslutningen misslyckades')
expect(html).toContain('annat användarkonto')
// The refused party learns nothing about the consent it tried to complete.
expect(html).not.toContain('consent-1')
// Error popups stay open (see oauth-callback-state.test.ts).
expect(html).not.toContain('window.close')
})
it('refuses a completion with no session at all and asks for a fresh connect', async () => {
useSession(null)
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
// The state is already spent (consumed atomically before the check), so
// the only way forward is a new connect, not a login-and-retry.
expect(consumeOAuthState).toHaveBeenCalledWith('otc')
expect(html).toContain('Logga in och starta om anslutningen')
expect(html).not.toContain('consent-1')
})
it('refuses a state row that records no initiator, with the generic state rejection', async () => {
// Rows minted before provider_otc.user_id existed: nobody to bind to.
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
userId: null,
})
useSession('user-1')
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(exchangeAuthToken).not.toHaveBeenCalled()
expect(mockCreateClient).not.toHaveBeenCalled()
expect(html).toContain(STATE_REJECTED)
})
it('reports valid tokens for the WRONG company in Swedish and never claims success', async () => {
useSession('user-1')
;(exchangeAuthToken as Mock).mockRejectedValue(
new ProviderCompanyMismatchError('5560160680', '5567037485', 'Annat Bolag AB'),
)
const res = await callbackHandler(callbackRequest({ code: 'provider-auth-code', state: 'otc' }))
const html = await res.text()
expect(html).toContain('Anslutningen misslyckades')
expect(html).toContain('Uppgifterna gäller ett annat företag')
expect(html).not.toContain('Provider company mismatch')
expect(html).not.toContain('Anslutningen lyckades')
})
})
describe('POST /connect: the state row records who started the flow', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.stubEnv('FORTNOX_REDIRECT_URI', '')
vi.spyOn(console, 'error').mockImplementation(() => {})
;(generateOtc as Mock).mockResolvedValue({ code: 'otc-code-1' })
;(getAuthUrl as Mock).mockResolvedValue({ url: 'https://apps.fortnox.se/oauth-v1/auth?x=1' })
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('binds a first connect to the caller', async () => {
;(listConsents as Mock).mockResolvedValue([])
;(createConsent as Mock).mockResolvedValue({ id: 'consent-new' })
const res = await connectHandler(
createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/connect`, {
method: 'POST',
body: { provider: 'fortnox' },
}),
connectCtx('user-1'),
)
expect(res.status).toBe(200)
expect(generateOtc).toHaveBeenCalledWith('consent-new', 'user-1', APP_URL)
})
it('binds a reconnect of an existing consent to the caller too', async () => {
;(listConsents as Mock).mockResolvedValue([{ id: 'consent-1', provider: 'fortnox', status: 1 }])
const res = await connectHandler(
createMockRequest(`${APP_URL}/api/extensions/ext/arcim-migration/connect`, {
method: 'POST',
body: { provider: 'fortnox', reconnect: true },
}),
connectCtx('user-1'),
)
expect(res.status).toBe(200)
expect(generateOtc).toHaveBeenCalledWith('consent-1', 'user-1', APP_URL)
})
})