fix(woocommerce): return the wc-auth browser leg to the brand host the connect started on (#2386)

* fix(woocommerce): return the wc-auth browser leg to the brand host the connect started on

Sessions are per domain. A white-label user who started a WooCommerce
connect on their brand domain was sent back by the store to the canonical
app URL, where the return leg's initiator check found no session and bounced
them to a foreign-branded login.

The connect route now resolves the request host through the trusted-origin
helper (brands-table validated, canonical on an unknown host or a failed
lookup) and builds the wc-auth return_url on that origin; the callback_url
stays on the canonical host because it is server-to-server and needs a
stable address. The return route resolves its panel redirect base from the
host it was reached on the same way. No stored origin column and no OTC
handoff: the wc-auth return_url is free-form per handshake, unlike a
registered OAuth redirect URI.

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

* test(woocommerce): name the state-less return test for what it asserts, drop the dead app-url stub

The return route now resolves its redirect base through the trusted-origin
helper, so a brand-host hit can do one cached brands lookup; the test only
ever asserted that woocommerce_connections is never touched, and now says so.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-07 18:40:45 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent f047c3d7d1
commit 57a5af1310
7 changed files with 117 additions and 7 deletions
@@ -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<typeof extensionRegistry.get>,
)
@@ -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' }))
@@ -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`