fix(banking): return Enable Banking consent callbacks to the initiating brand host (#2371)

* fix(banking): return Enable Banking consent callbacks to the initiating brand host

Enable Banking redirects every consent to the one canonical callback URL
while browser sessions are per host, so a white-label user reached the
callback signed out and was bounced to the unbranded canonical login. The
pending row now records the allowlisted origin the flow started from, and
the callback uses it for the login bounce, the success redirect and the
denial banner. The brand host already holds the session, so its /login
forwards straight back into the callback with cookies; the provider
redirect URI stays canonical, nothing changes in the Enable Banking
console. The shared login redirect helper also stops dragging a callback
that arrived on a registered brand host to the canonical login.

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

* fix(banking): reload the PostgREST schema cache after adding oauth_origin

Skeptic finding: every other ADD COLUMN migration ends with the NOTIFY,
and without it PostgREST can reject the new column on connect until its
cache refreshes.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-07 14:14:36 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 9879fb53e9
commit c634cf9ae0
9 changed files with 312 additions and 13 deletions
@@ -233,6 +233,96 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(chain.delete).not.toHaveBeenCalled()
})
// White-label: Enable Banking only ever redirects to the canonical
// callback, but the initiator's session lives on the brand host they
// started from. The row records that origin and every redirect from the
// callback goes back there; the brand host's /login forwards straight into
// the callback again because the session already exists.
describe('initiating origin (white-label)', () => {
const BRAND_ROW = { ...PENDING_ROW, oauth_origin: 'https://books.partner.example' }
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example')
})
afterEach(() => {
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '')
})
it('sends an anonymous white-label user to their own brand login, not the canonical one', async () => {
const chain = mockChain({ data: BRAND_ROW, error: null })
mockFrom.mockReturnValue(chain)
mockGetUser.mockResolvedValue({ data: { user: null }, error: null })
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(307)
const location = new URL(response.headers.get('location') || '')
expect(location.origin).toBe('https://books.partner.example')
expect(location.pathname).toBe('/login')
expect(location.searchParams.get('next')).toBe(
'/api/extensions/enable-banking/callback?code=auth-code&state=valid-state',
)
// Nothing consumed: the replay on the brand host completes the flow.
expect(mockCreateSession).not.toHaveBeenCalled()
expect(chain.update).not.toHaveBeenCalled()
expect(chain.delete).not.toHaveBeenCalled()
})
it('finalizes on the brand host: the finalize page redirects to the recorded origin', async () => {
mockConnectionFlow(BRAND_ROW)
mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null })
mockCreateSession.mockResolvedValue({
session_id: 'sess-1',
accounts: [],
access: { valid_until: '2027-12-31T00:00:00Z' },
aspsp: { name: 'TestBank', country: 'SE' },
})
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(200)
expect(await response.text()).toContain(
'https://books.partner.example/settings/banking?select_accounts=conn-1',
)
})
it('returns an initiator mismatch to the brand host', async () => {
mockFrom.mockReturnValue(mockChain({ data: BRAND_ROW, error: null }))
mockGetUser.mockResolvedValue({ data: { user: { id: 'user-2' } }, error: null })
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
const location = new URL(response.headers.get('location') || '')
expect(location.origin).toBe('https://books.partner.example')
expect(location.pathname).toBe('/settings/banking')
expect(location.searchParams.get('bank_error')).toContain('annat användarkonto')
})
it('collapses a recorded origin that is not a registered host to the canonical one', async () => {
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '')
const chain = mockChain({ data: BRAND_ROW, error: null })
mockFrom.mockReturnValue(chain)
mockGetUser.mockResolvedValue({ data: { user: null }, error: null })
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
const location = new URL(response.headers.get('location') || '')
expect(location.origin).toBe('http://localhost:3000')
expect(location.pathname).toBe('/login')
})
it('keeps a row without a recorded origin on the canonical host (pre-existing rows, direct domain)', async () => {
const chain = mockChain({ data: { ...PENDING_ROW, oauth_origin: null }, error: null })
mockFrom.mockReturnValue(chain)
mockGetUser.mockResolvedValue({ data: { user: null }, error: null })
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(new URL(response.headers.get('location') || '').origin).toBe('http://localhost:3000')
})
})
it('finalizes as before when the session belongs to the initiator', async () => {
// mockConnectionFlow is the suite's standard script for the finalize
// path (function declaration below, hoisted into this scope).
@@ -1705,6 +1795,33 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(location).toContain('psu_type=business')
})
it('returns a bank denial to the recorded brand origin so the banner is seen where the session is', async () => {
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'books.partner.example')
mockFrom.mockImplementation(() =>
mockChain({
data: {
id: 'conn-1',
user_id: 'user-1',
bank_name: 'Handelsbanken',
psu_type: 'business',
status: 'pending',
oauth_origin: 'https://books.partner.example',
},
error: null,
})
)
const response = await GET(makeRequest({ error: 'access_denied', state: 'pending-state' }))
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', '')
expect(response.status).toBe(307)
const location = new URL(response.headers.get('location') || '')
expect(location.origin).toBe('https://books.partner.example')
expect(location.pathname).toBe('/settings/banking')
expect(location.searchParams.get('bank_error_code')).toBe('access_denied')
expect(location.searchParams.get('bank_name')).toBe('Handelsbanken')
})
it('redirects with error when code or state is missing', async () => {
const response = await GET(makeRequest({ code: 'auth-code' }))