diff --git a/DECISIONS.md b/DECISIONS.md index 7f642e50..f5f4198a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1646,5 +1646,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-07] Line breaks in line descriptions are collapsed by every single-line consumer (Peppol cbc:Name, accrual voucher text) and additionally in the SIE writer's quoted-text escaper: SIE is one record per line by spec, so the writer guards the format regardless of where the text came from. [2026-09-07] Enable Banking callbacks return to the initiating white-label host by recording the allowlisted request origin on the pending row and replaying the callback there, instead of extending the provider_otc handoff from PR #2305: the brand host already holds the session, so a /login?next= bounce on that host forwards straight back into the callback with cookies, needing one nullable column and no encrypted payload, no second table and no cron. The provider redirect URI stays canonical, so nothing changes in the Enable Banking console. Stripe, Gmail and cloud backup have the same shape but no partner-domain users yet; tracked as a follow-up issue rather than built speculatively. [2026-09-07] Stripe checkout and portal return URLs resolve through the existing resolveRequestAppOrigin allowlist (NEXT_PUBLIC_WHITELABEL_DOMAINS), not a DB brand lookup: it is the same trust boundary invites and email-change links already use, so one allowlist governs every host we redirect a browser to. Return paths stay fixed literals; no caller-supplied URL is accepted. Session-expiry and company-switch handling were left alone: the middleware already bounces to /login on the same host with the path preserved, and the webhook keys on company_id metadata. +[2026-09-07] WooCommerce wc-auth return_url is built on the initiating request's trusted origin (resolveRequestAppOrigin, brands-table validated, canonical on unknown host or lookup failure) while callback_url stays on the canonical app URL: sessions are per domain, so a white-label user returned to the canonical host hit the initiator check with no session and a foreign-branded login. No stored origin column and no provider_otc handoff (PR #2305 pattern) needed, because unlike a registered OAuth redirect URI the wc-auth return_url is free-form per handshake and can simply point at the brand host; the return route resolves its redirect base from the host it was reached on the same way. [2026-09-07] PR #2373 review: the Skatteverket callback now peeks the flow row for its initiator and binds the session BEFORE consuming state or handoff (Superagent P2: a DELETE-before-auth let a signed-out or wrong-user arrival burn a live consent). A session-less arrival is sent to /login on the initiating origin and resumes into the same URL; a wrong user is refused with the row left claimable. This reverses the earlier 'no login resume' decision for this flow; the consume stays the atomic gate, the peek only decides who may attempt it. Handoff TTL raised from two to five minutes to fit a sign-in. [2026-09-07] BankID confirmation mail and the Send Email hook resolve their link host through lib/domains/trusted-app-origin instead of the raw forwarded host / GoTrue's redirect_to: the BankID mail is the one auth link GoTrue's redirect allowlist never sees (built here, sent via Resend), and the hook's signature proves the sender, not the destination, while the GoTrue allowlist is a hand-configured glob. Unknown, lookalike, credential-bearing, non-default-port and malformed destinations collapse to the canonical /auth/callback with no next path; a registered brand host over http is upgraded to https. Brand sender identity is resolved from the RESOLVED host so mail branding and link destination always agree. A brands-table read failure refuses (BankID: step resolve_origin, signup rolls back; hook: 500 so Supabase retries) rather than mailing a canonical link to a white-label user. Dropped from the audit's plan 7 as already in place after #2376: signup route, HTTPS enforcement, credential/port checks, recovery/invite/email-change coverage. diff --git a/app/api/extensions/woocommerce/__tests__/return.test.ts b/app/api/extensions/woocommerce/__tests__/return.test.ts index 8f93c46c..4f411328 100644 --- a/app/api/extensions/woocommerce/__tests__/return.test.ts +++ b/app/api/extensions/woocommerce/__tests__/return.test.ts @@ -8,8 +8,12 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) vi.mock('@/lib/events/bus', () => ({ eventBus: { emit: vi.fn() } })) vi.mock('@/lib/extensions/loader', () => ({ loadExtensions: vi.fn() })) vi.mock('@/lib/extensions/registry', () => ({ extensionRegistry: { get: vi.fn() } })) +vi.mock('@/lib/domains/trusted-app-origin', () => ({ + resolveRequestAppOrigin: vi.fn(async () => 'http://localhost:3000'), +})) import { GET } from '../return/route' +import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin' import { createServiceClient, createClient } from '@/lib/supabase/server' import { eventBus } from '@/lib/events/bus' import { extensionRegistry } from '@/lib/extensions/registry' @@ -58,7 +62,6 @@ const ACTIVATED = { describe('GET /api/extensions/woocommerce/return', () => { beforeEach(() => { vi.clearAllMocks() - vi.stubEnv('NEXT_PUBLIC_APP_URL', BASE) vi.mocked(extensionRegistry.get).mockReturnValue( { id: 'woocommerce' } as ReturnType, ) @@ -74,6 +77,35 @@ describe('GET /api/extensions/woocommerce/return', () => { expect(res.status).toBe(503) }) + it('redirects to the brand host the browser arrived on, resolved through the trusted-origin helper', async () => { + const { enqueue } = mockServiceClient() + mockSession('user-1') + vi.mocked(resolveRequestAppOrigin).mockResolvedValueOnce('https://app.testbrand.example') + enqueue({ data: ROW('pending') }) + enqueue({ data: null }) // browser_confirmed_at update + enqueue({ data: ACTIVATED }) // activateIfComplete + + const request = makeReturnRequest({ success: '1', user_id: STATE }) + const res = await GET(request) + + expect(resolveRequestAppOrigin).toHaveBeenCalledWith(request, { onLookupFailure: 'canonical' }) + expect(res.headers.get('location')).toBe( + 'https://app.testbrand.example/import?mode=woocommerce&woocommerce_connected=true', + ) + }) + + it('sends a denial back to the brand host too', async () => { + const { enqueue } = mockServiceClient() + vi.mocked(resolveRequestAppOrigin).mockResolvedValueOnce('https://app.testbrand.example') + enqueue({ data: null }) + + const res = await GET(makeReturnRequest({ success: '0', user_id: STATE })) + + expect(res.headers.get('location')).toBe( + 'https://app.testbrand.example/import?mode=woocommerce&woocommerce_error=denied', + ) + }) + it('closes the pending row and reports the denial when the store says no', async () => { const { supabase, enqueue, findCall } = mockServiceClient() enqueue({ data: null }) @@ -295,7 +327,7 @@ describe('GET /api/extensions/woocommerce/return', () => { expect(findCalls('woocommerce_connections', 'update')).toHaveLength(0) }) - it('redirects without a database round trip when the state is missing or not a uuid', async () => { + it('never touches woocommerce_connections when the state is missing or not a uuid', async () => { const { supabase } = mockServiceClient() const res1 = await GET(makeReturnRequest({ success: '1' })) diff --git a/app/api/extensions/woocommerce/return/route.ts b/app/api/extensions/woocommerce/return/route.ts index b9dac8d8..705243a6 100644 --- a/app/api/extensions/woocommerce/return/route.ts +++ b/app/api/extensions/woocommerce/return/route.ts @@ -5,6 +5,7 @@ import { eventBus } from '@/lib/events/bus' import { loadExtensions } from '@/lib/extensions/loader' import { extensionRegistry } from '@/lib/extensions/registry' import { createLogger } from '@/lib/logger' +import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin' import { requireFlowInitiator, FLOW_INITIATOR_MISMATCH_MESSAGE, @@ -62,7 +63,11 @@ export async function GET(request: Request) { const success = searchParams.get('success') const state = searchParams.get('user_id') - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + // The store redirected the browser to the origin the connect started on + // (buildAuthorizeUrl), where the session lives. Send the panel redirect to + // that same host, validated against the brands table; an unknown host or + // a failed lookup collapses to the canonical app URL. + const baseUrl = await resolveRequestAppOrigin(request, { onLookupFailure: 'canonical' }) // The WooCommerce surface lives on the import page; the base already has a // query, so appended params below must use '&'. const returnUrl = `${baseUrl}/import?mode=woocommerce` diff --git a/extensions/general/woocommerce/__tests__/api-routes.test.ts b/extensions/general/woocommerce/__tests__/api-routes.test.ts index 8d7051a2..eb7cf00a 100644 --- a/extensions/general/woocommerce/__tests__/api-routes.test.ts +++ b/extensions/general/woocommerce/__tests__/api-routes.test.ts @@ -20,6 +20,9 @@ vi.mock('../lib/order-sync', async (importOriginal) => { return { ...actual, syncWooCommerceOrders: vi.fn() } }) +vi.mock('@/lib/domains/trusted-app-origin', () => ({ + resolveRequestAppOrigin: vi.fn(async () => 'http://localhost:3000'), +})) vi.mock('@/lib/auth/api-keys', () => ({ createServiceClientNoCookies: vi.fn(() => ({ service: true })), })) @@ -31,6 +34,7 @@ import { testConnectionAndFetchStoreInfo } from '../lib/api-client' import { syncWooCommerceOrders } from '../lib/order-sync' import { decryptCredential } from '../lib/credentials' import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin' import { createQueuedMockSupabase } from '@/tests/helpers' import type { ExtensionContext } from '@/lib/extensions/types' @@ -181,6 +185,9 @@ describe('woocommerce extension routes', () => { expect(body.url).toContain( encodeURIComponent('http://localhost:3000/api/extensions/woocommerce/callback'), ) + expect(body.url).toContain( + encodeURIComponent('http://localhost:3000/api/extensions/woocommerce/return'), + ) const inserted = findCall('woocommerce_connections', 'insert')?.[0] as Record< string, unknown @@ -189,6 +196,30 @@ describe('woocommerce extension routes', () => { expect(inserted.status).toBe('pending') expect(inserted.oauth_state).toBeTruthy() }) + + it('sends the browser back to the brand host it started on while the callback stays canonical', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ data: { user: USER }, error: null }) + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: [] }) + enqueue({ data: { id: 'conn-1' } }) + vi.mocked(resolveRequestAppOrigin).mockResolvedValueOnce('https://app.testbrand.example') + const request = makeRequest('POST', { store_url: 'https://shop.example.se' }) + const res = await findRoute('POST', '/connect').handler(request, makeContext(supabase)) + expect(res.status).toBe(200) + const body = await res.json() + // Validated against the brands table by the helper; the route never + // trusts a raw Host header on its own. + expect(resolveRequestAppOrigin).toHaveBeenCalledWith(request, { + onLookupFailure: 'canonical', + }) + expect(body.url).toContain( + encodeURIComponent('https://app.testbrand.example/api/extensions/woocommerce/return'), + ) + expect(body.url).toContain( + encodeURIComponent('http://localhost:3000/api/extensions/woocommerce/callback'), + ) + }) }) describe('POST /manual-connect', () => { diff --git a/extensions/general/woocommerce/__tests__/connect.test.ts b/extensions/general/woocommerce/__tests__/connect.test.ts index e7e52b39..eb126cb3 100644 --- a/extensions/general/woocommerce/__tests__/connect.test.ts +++ b/extensions/general/woocommerce/__tests__/connect.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { SupabaseClient } from '@supabase/supabase-js' import { activateIfComplete, + buildAuthorizeUrl, expireStaleHandshakes, HANDSHAKE_TTL_MS, HANDSHAKE_EXPIRED_MESSAGE, @@ -11,6 +12,26 @@ import { createQueuedMockSupabase } from '@/tests/helpers' const asClient = (supabase: unknown) => supabase as SupabaseClient +describe('buildAuthorizeUrl', () => { + beforeEach(() => vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.canonical.example')) + afterEach(() => vi.unstubAllEnvs()) + + it('puts the browser return on the initiating origin and the server callback on the canonical host', () => { + const url = new URL( + buildAuthorizeUrl('https://shop.example.se', 'state-1', 'https://app.testbrand.example'), + ) + expect(url.origin + url.pathname).toBe('https://shop.example.se/wc-auth/v1/authorize') + expect(url.searchParams.get('return_url')).toBe( + 'https://app.testbrand.example/api/extensions/woocommerce/return', + ) + expect(url.searchParams.get('callback_url')).toBe( + 'https://app.canonical.example/api/extensions/woocommerce/callback', + ) + expect(url.searchParams.get('user_id')).toBe('state-1') + expect(url.searchParams.get('scope')).toBe('read') + }) +}) + describe('isHandshakeExpired', () => { it('is false inside the TTL and true past it', () => { const now = Date.parse('2026-09-07T12:00:00Z') diff --git a/extensions/general/woocommerce/api-routes.ts b/extensions/general/woocommerce/api-routes.ts index ccea88a6..74bfb1e9 100644 --- a/extensions/general/woocommerce/api-routes.ts +++ b/extensions/general/woocommerce/api-routes.ts @@ -5,6 +5,7 @@ import { requireCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard' import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin' import { isWooCommerceConfigured, encryptCredential } from './lib/credentials' import { normalizeStoreUrl, testConnectionAndFetchStoreInfo } from './lib/api-client' import { buildAuthorizeUrl } from './lib/connect' @@ -217,11 +218,20 @@ export const woocommerceApiRoutes: ApiRouteDefinition[] = [ ) } + // The browser comes back to the host it started on (brand domain or + // canonical), validated against the brands table: an unregistered + // Host header collapses to the canonical origin, as does a failed + // lookup (a wrong return host costs one bounce; a failed connect start + // would cost the whole flow). + const appOrigin = await resolveRequestAppOrigin(request, { + onLookupFailure: 'canonical', + }) + log.info('[woocommerce] Starting wc-auth handshake', { connection_id: created.id, company_id: auth.companyId, }) - return NextResponse.json({ url: buildAuthorizeUrl(storeUrl, oauthState) }) + return NextResponse.json({ url: buildAuthorizeUrl(storeUrl, oauthState, appOrigin) }) }, }, { diff --git a/extensions/general/woocommerce/lib/connect.ts b/extensions/general/woocommerce/lib/connect.ts index 64e04be9..8c017506 100644 --- a/extensions/general/woocommerce/lib/connect.ts +++ b/extensions/general/woocommerce/lib/connect.ts @@ -38,7 +38,17 @@ import type { WooCommerceConnection } from '../types' const APP_NAME = 'Accounted' -export function buildAuthorizeUrl(storeUrl: string, state: string): string { +/** + * @param appOrigin The trusted application origin the merchant started the + * connect on (canonical app URL or a registered white-label brand domain, + * already validated by resolveRequestAppOrigin). The BROWSER leg returns + * there: sessions are per domain, so a brand-domain user sent back to the + * canonical host would hit the initiator check with no session and land + * on a foreign-branded login. The server-to-server callback stays on the + * canonical host: no session is involved and the store must reach a + * stable URL. + */ +export function buildAuthorizeUrl(storeUrl: string, state: string, appOrigin: string): string { const baseUrl = process.env.NEXT_PUBLIC_APP_URL if (!baseUrl) throw new Error('NEXT_PUBLIC_APP_URL is not configured') const params = new URLSearchParams({ @@ -46,7 +56,7 @@ export function buildAuthorizeUrl(storeUrl: string, state: string): string { // Read-only: the feed never writes to the store. scope: 'read', user_id: state, - return_url: `${baseUrl}/api/extensions/woocommerce/return`, + return_url: `${appOrigin}/api/extensions/woocommerce/return`, callback_url: `${baseUrl}/api/extensions/woocommerce/callback`, }) return `${storeUrl}/wc-auth/v1/authorize?${params.toString()}`