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:
co-authored by
Claude Fable 5.1
parent
f047c3d7d1
commit
57a5af1310
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user