Files
accounted/extensions/general/woocommerce/lib/connect.ts
T
MattssonandClaude Fable 5.1 57a5af1310 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>
2026-09-07 18:40:45 +02:00

198 lines
8.4 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { WooCredentials } from './api-client'
import { decryptCredential } from './credentials'
import type { WooCommerceConnection } from '../types'
/**
* WooCommerce "Auth Endpoint" handshake helpers.
*
* The merchant's browser is sent to {store}/wc-auth/v1/authorize; after they
* approve, WooCommerce POSTs the generated consumer key/secret server-to-
* server to our callback_url and redirects the browser to return_url. Our
* oauth_state UUID rides in the handshake's user_id parameter and comes back
* in both places, tying callback and return to the pending connection row.
*
* There is no signature on the callback POST, so possession of the
* single-use state is the CSRF defense, and authenticity is proven by
* probing the STORED store_url with the received keys before activation: a
* forged POST would need working read credentials for the exact store the
* user asked to connect.
*
* Activation itself needs TWO signals on the pending row: the verified keys
* (callback leg, no browser session) and browser_confirmed_at (return leg,
* bound to the initiator's session). Either leg may land first; both run
* activateIfComplete() after writing their own signal, and the row flips to
* active exactly once, under the DB CHECK that forbids an active row missing
* either signal (migration 20260907143000). Every credential consumer selects
* status = 'active', so staged keys on a pending row can never sync.
*
* Scope of the guarantee: a connection can never go live headless (callback
* alone) or under a session other than the initiator's, and abandoned
* handshakes are parked with their keys wiped. It is NOT a defense against a
* store admin approving a link the initiator generated for them: wc-auth
* cannot tell us who approved, and the initiator legitimately supplies the
* second signal. The confirmation column is member-writable through RLS
* like every other column on this row-scoped table; it records that the
* handshake completed under a session, it is not a trust boundary.
*/
const APP_NAME = 'Accounted'
/**
* @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({
app_name: APP_NAME,
// Read-only: the feed never writes to the store.
scope: 'read',
user_id: state,
return_url: `${appOrigin}/api/extensions/woocommerce/return`,
callback_url: `${baseUrl}/api/extensions/woocommerce/callback`,
})
return `${storeUrl}/wc-auth/v1/authorize?${params.toString()}`
}
/**
* A pending handshake older than this is dead: the return leg refuses it,
* activateIfComplete() will not flip it, and the nightly cron parks it with
* its staged keys wiped. The callback leg does NOT refuse it: WooCommerce
* turns any non-200 into a store-side error page and deletes the freshly
* minted key, which would strand a slow but legitimate approval; staging on
* a stale row is harmless because a pending row never syncs and the flip
* itself carries the TTL.
*/
export const HANDSHAKE_TTL_MS = 15 * 60_000
export const HANDSHAKE_EXPIRED_MESSAGE =
'Anslutningen gick ut innan den slutfördes. Starta om anslutningen och ta bort den oanvända API-nyckeln i butikens WooCommerce-inställningar.'
export function isHandshakeExpired(createdAt: string, now = Date.now()): boolean {
return now - new Date(createdAt).getTime() > HANDSHAKE_TTL_MS
}
export interface ActivatedConnection {
id: string
company_id: string
user_id: string
store_url: string
}
export type ActivationResult =
| { outcome: 'activated'; connection: ActivatedConnection }
/** The other signal has not landed yet (or the row is no longer pending). */
| { outcome: 'incomplete' }
/** 23505: the store is already actively connected (this or another company). */
| { outcome: 'conflict'; error: { code?: string; message: string } }
| { outcome: 'failed'; error: { code?: string; message: string } }
/**
* Flip a pending row to active if, and only if, both signals are present.
*
* Both handshake legs call this after writing their own signal. Postgres
* serializes the two UPDATEs on the row, and the WHERE is re-evaluated
* against the latest committed version, so whichever leg runs last sees both
* signals and activates; the other matches zero rows and reports incomplete.
* The status = 'pending' scope makes it idempotent and blocks replay: an
* active row can never be activated again.
*
* The TTL is part of the predicate, not only of the return leg: the
* initiator can confirm early (the return URL is theirs to open), so
* without it a row confirmed at minute 1 would still flip when the keys
* land hours later. Past the TTL the flip matches zero rows and the sweep
* parks the row; the callback still answers 200 so the store does not
* wp_die on the merchant.
*/
export async function activateIfComplete(
supabase: SupabaseClient,
connectionId: string,
now = Date.now(),
): Promise<ActivationResult> {
const { data, error } = await supabase
.from('woocommerce_connections')
.update({
status: 'active',
connected_at: new Date().toISOString(),
error_message: null,
// The state has done its job once both legs have found the row.
oauth_state: null,
// Feed-only product: connecting the store means fetching its orders, so
// the nightly feed starts on by default; the panel toggle is the opt-out.
transaction_sync_enabled: true,
})
.eq('id', connectionId)
.eq('status', 'pending')
.not('consumer_key_encrypted', 'is', null)
.not('consumer_secret_encrypted', 'is', null)
.not('browser_confirmed_at', 'is', null)
.gte('created_at', new Date(now - HANDSHAKE_TTL_MS).toISOString())
.select('id, company_id, user_id, store_url')
.maybeSingle()
if (error) {
const err = { code: error.code, message: error.message }
return error.code === '23505'
? { outcome: 'conflict', error: err }
: { outcome: 'failed', error: err }
}
if (!data) return { outcome: 'incomplete' }
return { outcome: 'activated', connection: data as ActivatedConnection }
}
/**
* Park every pending handshake older than the TTL: status error, staged keys
* wiped, state consumed. Nothing on a pending row can sync, so this is
* hygiene for encrypted-at-rest secrets rather than a security boundary;
* running it once a night (from the orders cron) is enough.
*/
export async function expireStaleHandshakes(
supabase: SupabaseClient,
now = Date.now(),
): Promise<{ expired: number; error: { code?: string; message: string } | null }> {
const { data, error } = await supabase
.from('woocommerce_connections')
.update({
status: 'error',
error_message: HANDSHAKE_EXPIRED_MESSAGE,
oauth_state: null,
consumer_key_encrypted: null,
consumer_secret_encrypted: null,
store_name: null,
currency: null,
prices_include_tax: null,
wc_version: null,
key_permissions: null,
})
.eq('status', 'pending')
.lt('created_at', new Date(now - HANDSHAKE_TTL_MS).toISOString())
.select('id')
if (error) return { expired: 0, error: { code: error.code, message: error.message } }
return { expired: data?.length ?? 0, error: null }
}
/** Decrypted API credentials for an active connection. */
export function credentialsOf(
connection: Pick<
WooCommerceConnection,
'store_url' | 'consumer_key_encrypted' | 'consumer_secret_encrypted'
>,
): WooCredentials {
if (!connection.consumer_key_encrypted || !connection.consumer_secret_encrypted) {
throw new Error('Connection has no stored credentials')
}
return {
storeUrl: connection.store_url,
consumerKey: decryptCredential(connection.consumer_key_encrypted),
consumerSecret: decryptCredential(connection.consumer_secret_encrypted),
}
}