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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user