feat(connect): Enable Banking client routes through the hosted proxy in connector mode (PR6b-1) (#2094)

* feat(connect): route the Enable Banking client through the hosted proxy in connector mode

PR6b-1 of the instance-side client wiring. Until now bankConnectorMode()
had no consumer but the status label; this makes a self-host with a
connector key and no own EB credentials actually reach Enable Banking
through the hosted bank proxy.

- api-client authenticatedFetch: in connector mode swap the base URL to
  the proxy and send the connector key as a Bearer token. The EB JWT
  signer (getAuthorizationHeader) is never called: the instance holds no
  private key. On hosted and on own-credentials self-hosts the direct
  path is byte-identical.
- startAuthorization forwards X-Connector-Company so the proxy can meter
  the per-company connection quota; index.ts passes companyId at both
  connect sites.
- createSession forwards the signed connector_state so the proxy binds
  the /sessions exchange to the pending ledger row (single-use, race-safe).
- callback route reads connector_state from the query (echoed by the
  hosted callback) and threads it through finalizeConnection.

Tests: connector-mode base/auth/company-header/connector_state assertions
in api-client, direct-path and own-credentials byte-identity, and the
callback threading both connector and direct paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

* fix(connect): gate X-Connector-Company on connector mode, not on companyId

Skeptic regression finding: index.ts passes companyId to startAuthorization
unconditionally, and the header was attached whenever companyId was truthy.
On hosted and on own-credentials self-hosts companyId is always set, so every
direct POST /auth to the real Enable Banking API carried the tenant's internal
company UUID: a needless behavior change on the production path and an
identifier leak to a third-party PSD2 processor (the "byte-identical direct
path" claim was false).

Gate the header on bankConnectorMode() so it is sent only when the request
actually goes to the hosted proxy. Adds a direct-path test asserting the header
is absent even when companyId is passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 10:26:40 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e113e9c099
commit 05dce83a2b
5 changed files with 184 additions and 10 deletions
@@ -128,6 +128,48 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(decodeURIComponent(location)).toContain('Starta bankkopplingen på nytt')
})
it('threads connector_state from the query into createSession (connector mode)', async () => {
// In connector mode the hosted callback bounces the browser back here with
// the signed connector_state echoed alongside code + the instance's own
// oauth_state. createSession must forward it so the bank proxy binds the
// /sessions exchange to the pending ledger row it signed at /auth time.
mockFrom.mockImplementation(() =>
mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
error: null,
}),
)
mockCreateSession.mockResolvedValue({
session_id: 'sess-1',
accounts: [],
access: { valid_until: '2027-12-31T00:00:00Z' },
aspsp: { name: 'TestBank', country: 'SE' },
})
await GET(makeRequest({ code: 'auth-code', state: 'valid-state', connector_state: 'signed-connector-state' }))
expect(mockCreateSession).toHaveBeenCalledWith('auth-code', 'signed-connector-state')
})
it('passes undefined connector_state on the direct path (no connector_state in the query)', async () => {
mockFrom.mockImplementation(() =>
mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
error: null,
}),
)
mockCreateSession.mockResolvedValue({
session_id: 'sess-1',
accounts: [],
access: { valid_until: '2027-12-31T00:00:00Z' },
aspsp: { name: 'TestBank', country: 'SE' },
})
await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(mockCreateSession).toHaveBeenCalledWith('auth-code', undefined)
})
it('writes pending_selection and streams a finalizing page that redirects to the picker', async () => {
const capturedUpdates: Record<string, unknown>[] = []
let callIndex = 0
@@ -80,6 +80,10 @@ export async function GET(request: Request) {
const state = searchParams.get('state') // Cryptographic oauth_state token
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
// Present in connector mode only: the hosted callback echoes the signed
// connector state back to this instance so createSession can bind the proxy's
// /sessions exchange to the pending ledger row. Null on the direct path.
const connectorState = searchParams.get('connector_state')
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
@@ -269,7 +273,7 @@ export async function GET(request: Request) {
// failures resolve to the cleanup redirect target.
const finalizePromise = (async (): Promise<string> => {
try {
return await finalizeConnection(supabase, pendingConnection, code)
return await finalizeConnection(supabase, pendingConnection, code, connectorState)
} catch (finalizeError) {
const reason =
finalizeError instanceof Error ? finalizeError.message : String(finalizeError)
@@ -370,6 +374,7 @@ async function finalizeConnection(
supabase: ServiceClient,
pendingConnection: PendingConnection,
code: string,
connectorState: string | null,
): Promise<string> {
const userId = pendingConnection.user_id
@@ -379,7 +384,7 @@ async function finalizeConnection(
codeLength: code.length,
})
const sessionData = await createSession(code)
const sessionData = await createSession(code, connectorState ?? undefined)
const { session_id, accounts, access } = sessionData
const consentExpiresAt = access.valid_until
+4 -2
View File
@@ -590,7 +590,8 @@ export const enableBankingExtension: Extension = {
redirectUrl,
oauthState,
psuType,
authMethod
authMethod,
companyId
)
// Record the bank's authorization_id for audit/traceability. The
@@ -623,7 +624,8 @@ export const enableBankingExtension: Extension = {
redirectUrl,
oauthState,
psuType,
authMethod
authMethod,
companyId
)
const { data: connection, error } = await supabase
@@ -19,6 +19,8 @@ import {
getAllTransactionsWithRaw,
convertTransaction,
probeSessionHealth,
startAuthorization,
createSession,
type Transaction,
} from '../api-client'
@@ -571,3 +573,100 @@ describe('probeSessionHealth', () => {
expect(await probeSessionHealth('s1')).toBe('unknown')
})
})
// ---------------------------------------------------------------------------
// Connector mode (self-host routes upstream through the hosted bank proxy)
// ---------------------------------------------------------------------------
describe('connector mode', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
const okJson = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
beforeEach(() => {
vi.clearAllMocks()
// A self-host with a connector key and no own EB credentials. The
// own-credentials env vars must stay unset for bankConnectorMode() to
// engage (key present AND no own credentials).
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_testsecret')
vi.stubEnv('GNUBOK_CONNECT_URL', 'https://app.test.example')
vi.stubEnv('ENABLE_BANKING_PRIVATE_KEY', '')
vi.stubEnv('ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', '')
vi.stubEnv('ENABLE_BANKING_APP_ID', '')
vi.stubEnv('ENABLE_BANKING_APP_ID_PRODUCTION', '')
fetchSpy = vi.spyOn(globalThis, 'fetch')
})
afterEach(() => {
fetchSpy.mockRestore()
vi.unstubAllEnvs()
})
const lastCall = () => {
const call = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1]
const url = String(call[0])
const init = (call[1] ?? {}) as RequestInit
const headers = (init.headers ?? {}) as Record<string, string>
return { url, init, headers }
}
it('routes reads through the proxy with the connector key, never the EB JWT', async () => {
fetchSpy.mockResolvedValue(okJson({ aspsps: [] }))
await getASPSPs('SE')
const { url, headers } = lastCall()
expect(url).toContain('https://app.test.example/api/connect/bank/aspsps')
expect(headers['Authorization']).toBe('Bearer gnubok_ck_testsecret')
expect(headers['Authorization']).not.toContain('jwt')
// The JWT signer must not run: the instance holds no EB private key.
expect(mockGenerateJWT).not.toHaveBeenCalled()
})
it('sends X-Connector-Company on /auth so the proxy can meter the company quota', async () => {
fetchSpy.mockResolvedValue(okJson({ url: 'https://bank/auth', authorization_id: 'a1' }))
await startAuthorization('Bank', 'SE', 'https://instance.test/callback', 'oauth-state-1', 'business', undefined, 'company-42')
const { url, headers, init } = lastCall()
expect(url).toBe('https://app.test.example/api/connect/bank/auth')
expect(init.method).toBe('POST')
expect(headers['X-Connector-Company']).toBe('company-42')
expect(headers['Authorization']).toBe('Bearer gnubok_ck_testsecret')
})
it('binds /sessions to the signed connector_state when one is passed', async () => {
fetchSpy.mockResolvedValue(okJson({ session_id: 's1', accounts: [], access: { valid_until: '2027-01-01' } }))
await createSession('auth-code', 'signed-connector-state')
const { url, init } = lastCall()
expect(url).toBe('https://app.test.example/api/connect/bank/sessions')
expect(JSON.parse(String(init.body))).toEqual({ code: 'auth-code', connector_state: 'signed-connector-state' })
})
it('omits connector_state from /sessions when none is passed', async () => {
fetchSpy.mockResolvedValue(okJson({ session_id: 's1', accounts: [], access: { valid_until: '2027-01-01' } }))
await createSession('auth-code')
const { init } = lastCall()
expect(JSON.parse(String(init.body))).toEqual({ code: 'auth-code' })
})
it('does not engage when the instance has its own EB credentials (own-credentials seam)', async () => {
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
fetchSpy.mockResolvedValue(okJson({ aspsps: [] }))
await getASPSPs('SE')
const { url, headers } = lastCall()
// Direct EB base (captured at import), never the connector proxy.
expect(url).not.toContain('/api/connect/bank')
expect(url).toContain('enablebanking.com')
expect(headers['Authorization']).toBe('Bearer test-jwt-token')
})
it('never sends X-Connector-Company on the direct path, even with companyId passed', async () => {
// Own EB credentials → direct path. companyId is always set on hosted /auth,
// so the header must be gated on connector mode, not on companyId: leaking
// the internal company UUID to the real Enable Banking API is a regression.
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
fetchSpy.mockResolvedValue(okJson({ url: 'https://bank/auth', authorization_id: 'a1' }))
await startAuthorization('Bank', 'SE', 'https://instance.test/callback', 'oauth-state-1', 'business', undefined, 'company-42')
const { url, headers } = lastCall()
expect(url).toContain('enablebanking.com')
expect(headers['X-Connector-Company']).toBeUndefined()
expect(headers['Authorization']).toBe('Bearer test-jwt-token')
})
})
@@ -15,6 +15,7 @@
import { getAuthorizationHeader } from './jwt'
import { deriveTransactionLabel } from './transaction-label'
import { FALLBACK_DESCRIPTION } from '@/lib/transactions/external-id'
import { bankConnectorMode, CONNECTOR_COMPANY_HEADER } from '@/lib/connect/instance/upstreams'
// Prefer _PRODUCTION variant; sandbox uses api.tilisy.com, production uses api.enablebanking.com
const ENABLE_BANKING_API_URL =
@@ -264,7 +265,16 @@ async function authenticatedFetch(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
const url = `${ENABLE_BANKING_API_URL}${endpoint}`
// Connector mode: a self-host with a connector key and no own Enable Banking
// credentials routes every upstream call through the hosted bank proxy. The
// proxy holds the real EB credentials and mints the JWT on its side, so the
// instance sends the connector key as a Bearer token and NEVER calls
// getAuthorizationHeader() (there is no private key to sign with here). When
// this instance has its own EB credentials, or on hosted, bankConnectorMode()
// returns null and the direct path below is byte-identical to before.
const connector = bankConnectorMode()
const url = connector ? `${connector.baseUrl}${endpoint}` : `${ENABLE_BANKING_API_URL}${endpoint}`
const authorization = connector ? `Bearer ${connector.key}` : getAuthorizationHeader()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
@@ -273,7 +283,7 @@ async function authenticatedFetch(
...options,
signal: controller.signal,
headers: {
'Authorization': getAuthorizationHeader(),
'Authorization': authorization,
'Content-Type': 'application/json',
...options.headers,
},
@@ -490,7 +500,8 @@ export async function startAuthorization(
redirectUrl: string,
state: string,
psuType: 'personal' | 'business' = 'personal',
authMethod?: string
authMethod?: string,
companyId?: string
): Promise<AuthResponse> {
// Calculate consent validity (90 days)
const validUntil = new Date()
@@ -519,9 +530,20 @@ export async function startAuthorization(
requestBody.auth_method = authMethod
}
// In connector mode the hosted bank proxy meters the per-company connection
// quota, so it needs to know which company this authorization is for. This is
// gated on bankConnectorMode(), NOT merely on companyId: on hosted and on
// own-credentials self-hosts companyId is always set, and sending an internal
// company UUID to the real Enable Banking API is both a needless behavior
// change and an identifier leak to a third-party processor. Off the connector
// path the direct request stays byte-identical.
const authHeaders =
companyId && bankConnectorMode() ? { [CONNECTOR_COMPANY_HEADER]: companyId } : undefined
const response = await authenticatedFetch('/auth', {
method: 'POST',
body: JSON.stringify(requestBody)
body: JSON.stringify(requestBody),
...(authHeaders ? { headers: authHeaders } : {}),
})
if (!response.ok) {
@@ -547,11 +569,15 @@ export async function startAuthorization(
* Create a session after user completes bank authorization
*
* @param code - The authorization code from callback
* @param connectorState - The signed connector state echoed back through the
* hosted callback in connector mode. The bank proxy binds the /sessions
* exchange to the pending row it signed at /auth time (single-use, race-safe),
* so it is required in connector mode and absent on the direct path.
*/
export async function createSession(code: string): Promise<SessionResponse> {
export async function createSession(code: string, connectorState?: string): Promise<SessionResponse> {
const response = await authenticatedFetch('/sessions', {
method: 'POST',
body: JSON.stringify({ code })
body: JSON.stringify(connectorState ? { code, connector_state: connectorState } : { code })
})
if (!response.ok) {