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
+1
View File
@@ -1646,5 +1646,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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=<callback> 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.
@@ -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`
@@ -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()}`