fix(arcim): resolve OAuth redirect_uri identically in authorize and exchange (#1287)

* fix(arcim): resolve OAuth redirect_uri identically in authorize and exchange

The authorize leg honored the FORTNOX_REDIRECT_URI / VISMA_REDIRECT_URI
override while the token-exchange leg hardcoded the NEXT_PUBLIC_APP_URL
fallback. After the app-domain cutover (2026-07-21) the Fortnox env var
still pointed at app.gnubok.se while NEXT_PUBLIC_APP_URL moved to
app.accounted.se, so the two redirect_uri values differed and Fortnox
rejected every code exchange (RFC 6749 4.1.3). The failure was invisible:
the error popup posted its message from the old-domain origin, the
wizard's event.origin check dropped it, and the popup closed itself.

Both legs now resolve through one resolveArcimCallbackUrl() helper, and
the error popup stays open with the reason on screen so a dropped
postMessage can never again turn into "nothing happens".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log OAuth popup and redirect-uri rollout decisions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-29 18:47:07 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent ef25a87d75
commit 16f34fb214
3 changed files with 175 additions and 18 deletions
@@ -45,11 +45,22 @@ vi.mock('../lib/provider-client', () => ({
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
// The /connect handler unconditionally imports this module (for its
// pending-consent token check); the real one pulls in next/headers.
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { arcimMigrationExtension } from '../index'
import {
consumeOAuthState,
exchangeAuthToken,
getConsent,
createConsent,
listConsents,
generateOtc,
getAuthUrl,
ConsentNotFoundError,
} from '../lib/provider-client'
@@ -84,6 +95,10 @@ describe('GET /callback: OAuth state binding', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
// The exchange redirect_uri now consults the per-provider override; keep
// these tests on the NEXT_PUBLIC_APP_URL fallback regardless of local env.
vi.stubEnv('FORTNOX_REDIRECT_URI', '')
vi.stubEnv('VISMA_REDIRECT_URI', '')
// The callback route is dispatched without an ExtensionContext (skipAuth
// routes get no ctx), so console is the logger. Keep the output quiet.
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -238,6 +253,124 @@ describe('GET /callback: full-page fallback when there is no opener', () => {
})
})
/**
* The redirect_uri sent in the authorization request and the one sent in the
* token exchange must be byte-identical (RFC 6749 §4.1.3) or the provider
* rejects the code exchange. These broke apart once already: the authorize leg
* honored the FORTNOX_REDIRECT_URI override while the exchange hardcoded the
* NEXT_PUBLIC_APP_URL fallback, so when the app moved to app.accounted.se and
* the env var still pointed at app.gnubok.se, every Fortnox connect died at
* the exchange with no visible error (the error postMessage was then dropped
* by the opener's origin check). Both legs now resolve through
* resolveArcimCallbackUrl; these tests pin the symmetry.
*/
describe('OAuth redirect_uri symmetry between authorize and exchange', () => {
const OVERRIDE_URI = 'https://dev-tunnel.example.test/api/extensions/ext/arcim-migration/callback'
const connectHandler = findRoute('POST', '/connect').handler as RouteHandler
function connectCtx(): ExtensionContext {
const { supabase } = createMockSupabase()
;(supabase as unknown as { auth: unknown }).auth = {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
}
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
}
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.stubEnv('FORTNOX_REDIRECT_URI', OVERRIDE_URI)
vi.spyOn(console, 'error').mockImplementation(() => {})
;(listConsents as Mock).mockResolvedValue([])
;(createConsent as Mock).mockResolvedValue({ id: 'consent-new' })
;(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('authorize leg passes the env-override redirect URI to getAuthUrl', async () => {
const res = await connectHandler(
createMockRequest('http://localhost/api/extensions/ext/arcim-migration/connect', {
method: 'POST',
body: { provider: 'fortnox' },
}),
connectCtx(),
)
expect(res.status).toBe(200)
expect(getAuthUrl).toHaveBeenCalledWith('fortnox', 'otc-code-1', OVERRIDE_URI)
})
it('exchange leg passes the SAME env-override redirect URI to exchangeAuthToken', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-new',
provider: 'fortnox',
})
await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
expect(exchangeAuthToken).toHaveBeenCalledWith(
'consent-new',
'fortnox',
'provider-auth-code',
OVERRIDE_URI,
)
})
})
/**
* The error page must stay open: its postMessage is dropped whenever the
* popup's origin differs from the opener's, and a window.close() right after
* turns that into "I approve in Fortnox and then nothing happens". The success
* page still closes itself.
*/
describe('GET /callback: error popup stays open, success popup closes', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_APP_URL', APP_URL)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('keeps the error popup open with the reason visible', async () => {
;(consumeOAuthState as Mock).mockResolvedValue(null)
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'bad-token' }),
)
const html = await res.text()
expect(html).toContain('Anslutningen misslyckades')
expect(html).not.toContain('window.close')
})
it('still closes the success popup', async () => {
;(consumeOAuthState as Mock).mockResolvedValue({
consentId: 'consent-1',
provider: 'fortnox',
})
const res = await callbackHandler(
callbackRequest({ code: 'provider-auth-code', state: 'one-time-token' }),
)
const html = await res.text()
expect(html).toContain('Anslutningen lyckades')
expect(html).toContain('window.close()')
})
})
describe('GET /preview: cross-tenant consent status oracle', () => {
beforeEach(() => {
vi.clearAllMocks()
+40 -18
View File
@@ -66,6 +66,34 @@ function translateOAuthError(error: string, description: string | null): string
return description ? `${error}: ${description}` : error
}
/**
* Resolve the OAuth callback URL for a provider. Single source of truth for
* BOTH legs of the flow: the redirect_uri sent in the authorization request
* and the redirect_uri sent in the token exchange must be byte-identical, or
* the provider rejects the code exchange (RFC 6749 §4.1.3). The two legs used
* to resolve this independently: authorize honored the FORTNOX_REDIRECT_URI /
* VISMA_REDIRECT_URI override while the exchange hardcoded the
* NEXT_PUBLIC_APP_URL fallback, so any override differing from the fallback
* (dev ngrok URI, or an env var left on the old app domain after a domain
* cutover) silently broke every OAuth connect at the exchange step.
*
* The override exists so dev environments can route through a single
* registered URI instead of registering every ngrok URL on the OAuth client.
*/
function resolveArcimCallbackUrl(provider: ArcimProvider | ProviderName): string {
const providerRedirectEnv =
provider === 'visma'
? process.env.VISMA_REDIRECT_URI
: provider === 'fortnox'
? process.env.FORTNOX_REDIRECT_URI
: undefined
if (providerRedirectEnv && providerRedirectEnv.trim().length > 0) {
return providerRedirectEnv
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
return `${appUrl}/api/extensions/ext/arcim-migration/callback`
}
/**
* Build a provider OAuth authorization URL bound to an EXISTING consent id.
* Used by both first-time connect and reconnect (token revival): the callback
@@ -79,21 +107,7 @@ async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): P
// is that row's opaque random primary key, nothing more.
const otc = await generateOtc(consentId)
// Prefer a provider-specific redirect override (e.g. VISMA_REDIRECT_URI) when
// set: lets dev environments route through a single registered URI rather
// than registering every ngrok URL on the OAuth client. Falls back to
// NEXT_PUBLIC_APP_URL + the canonical callback path.
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
const providerRedirectEnv =
provider === 'visma'
? process.env.VISMA_REDIRECT_URI
: provider === 'fortnox'
? process.env.FORTNOX_REDIRECT_URI
: undefined
const callbackUrl =
providerRedirectEnv && providerRedirectEnv.trim().length > 0
? providerRedirectEnv
: `${appUrl}/api/extensions/ext/arcim-migration/callback`
const callbackUrl = resolveArcimCallbackUrl(provider)
// The state is the one-time code itself: an unguessable pointer to the row
// above. It deliberately encodes NOTHING. The previous base64url JSON payload
@@ -459,14 +473,20 @@ export const arcimMigrationExtension: Extension = {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// The popup deliberately stays open on error: the postMessage is
// dropped whenever the popup's origin differs from the opener's
// (browser targetOrigin/origin checks), and closing anyway turns any
// such config drift into an invisible failure the user can only
// describe as "nothing happens". Leaving the reason on screen keeps
// every error diagnosable; the wizard also shows it when the message
// does arrive.
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = ${jsLiteral(fallbackUrl.toString())};
}
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
</script><p>Anslutningen misslyckades: ${escapedReason}</p><p>Du kan stänga detta fönster.</p></body></html>`
return new Response(html, {
status: 200,
@@ -518,7 +538,9 @@ export const arcimMigrationExtension: Extension = {
const { consentId, provider } = resolvedState
const redirectUri = `${appUrl}/api/extensions/ext/arcim-migration/callback`
// 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)