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
@@ -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')
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()}`
|
||||
|
||||
Reference in New Issue
Block a user