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
@@ -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', () => {
@@ -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')
+11 -1
View File
@@ -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) })
},
},
{
+12 -2
View File
@@ -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()}`