diff --git a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts index f38d73ad..44acd30a 100644 --- a/app/api/extensions/enable-banking/callback/__tests__/route.test.ts +++ b/app/api/extensions/enable-banking/callback/__tests__/route.test.ts @@ -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[] = [] let callIndex = 0 diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index 1f2bd1a0..b9f167bf 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -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 => { 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 { 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 diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index b4e61d2a..5f213ca2 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -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 diff --git a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts index 6bff2e8f..75105f22 100644 --- a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts @@ -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 + + 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 + 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') + }) +}) diff --git a/extensions/general/enable-banking/lib/api-client.ts b/extensions/general/enable-banking/lib/api-client.ts index c67b6698..eb42852a 100644 --- a/extensions/general/enable-banking/lib/api-client.ts +++ b/extensions/general/enable-banking/lib/api-client.ts @@ -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 { - 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 { // 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 { +export async function createSession(code: string, connectorState?: string): Promise { const response = await authenticatedFetch('/sessions', { method: 'POST', - body: JSON.stringify({ code }) + body: JSON.stringify(connectorState ? { code, connector_state: connectorState } : { code }) }) if (!response.ok) {