* 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>
198 lines
8.4 KiB
TypeScript
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),
|
|
}
|
|
}
|