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:
co-authored by
Claude Fable 5
parent
e113e9c099
commit
05dce83a2b
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user