Files
accounted/extensions/general/woocommerce/lib/connect.ts
T
MattssonandClaude Fable 5.1 e0244b95a7 fix(woocommerce): require both verified keys and browser confirmation to activate a connection (#2375)
* fix(woocommerce): require both verified keys and browser confirmation to activate a connection

The wc-auth callback alone used to flip a connection to active. Until the
initiator's browser reached the return route the row was syncable, so an
approver who never came back (closed tab, skipped sign-in, or lured into
approving a connect someone else started) left their store's keys active
inside another company's books, reachable by manual sync within seconds.

Now the callback only stages the verified keys on the pending row, the
return leg records browser_confirmed_at after the initiator check, and one
conditional update flips the row to active exactly once when both signals
are present, in either arrival order. A DB CHECK (20260907100000) makes an
active row without both signals impossible; every consumer selects
status = 'active', so staged keys can never sync.

Also: 15-minute handshake TTL on both legs, duplicate callback refused,
stale pending rows swept (keys wiped) at the start of the nightly orders
cron, manual key entry records the confirmation itself, every path that
closes a pending row wipes staged keys, expired/conflict toasts in sv + en.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* fix(woocommerce): drop the callback-leg TTL and state the gate's real scope

Skeptic pass on the activation gate:

- WooCommerce answers any non-200 callback response by deleting the key it
  just minted and showing a store-side error page, never redirecting back.
  A 410 for a slow approval therefore stranded the merchant. The callback
  now stages regardless of age; the session-bound return leg and the nightly
  sweep enforce expiry, and a stale pending row cannot sync either way.
- The second activation signal comes from the initiating user, so the gate
  does not stop a store admin from approving a link someone else generated
  (wc-auth delivers keys server-to-server and identifies no approver). The
  migration header, route comments, decision log and PR body now say so
  instead of claiming otherwise. Proof of store control is a follow-up.
- Every path that parks a pending row also wipes the store metadata the
  probe staged, so a refused handshake leaves nothing of the store behind.
- A duplicate callback POST is a 200 no-op instead of a 409, so the status
  code no longer tells the state holder whether the merchant has approved.
- The expiry message tells the user to remove the unused key in the store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* fix(woocommerce): carry the handshake TTL in the activation flip, wipe metadata on denial

Re-verification of the skeptic fixes found two gaps:

- The initiator can open the return URL early, so a row confirmed at
  minute one still flipped when the keys landed hours later; the callback
  no longer refuses stale rows, so nothing bounded that. The conditional
  activation update now also requires created_at within the TTL. The
  callback still answers 200 (no store-side wp_die); the flip matches zero
  rows and the sweep parks the row. Covered by a pg-real case with both
  signals present on a 20-minute-old row.
- The store-denied path parked the row without clearing the staged store
  metadata. It now wipes the same five columns as every other parking path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* chore(migrations): move the WooCommerce activation gate to 20260907143000 after main took 20260907100000

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* fix(woocommerce): make browser_confirmed_at server-only, finish replayed callbacks, budget the cron sweep

Review round on PR #2375:

- Superagent P1: browser_confirmed_at was member-writable through the
  row-scoped RLS policy, so any writer of the company could supply the
  initiator's signal on a colleague's pending row. New migration
  20260907150000 adds a trigger keyed on the JWT role claim (same pattern as
  enforce_company_writer_role): end-user sessions cannot insert or update
  the column, service role and migrations pass. Manual key entry now
  inserts on the service client with company_id/user_id from the verified
  context. pg-real covers refusal on insert and update plus the server path.
- CodeRabbit: a replayed callback for an already-keyed row now runs the
  activation flip instead of returning early, so a callback cut off between
  staging and activating is completed by its retry.
- CodeRabbit: the cron deadline is fixed before the stale-handshake sweep,
  so the sweep counts against the route's maxDuration budget.
- CodeRabbit: the activation CHECK is added NOT VALID (rows were already
  conformed by the backfill) and validated in 20260907150000 under the
  weaker lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz

* fix(woocommerce): make activation itself server-only, install the trigger before validating the gate

Second review round on PR #2375:

- CodeRabbit: an authenticated company member could still UPDATE ... SET
  status = 'active' on a fully staged row through PostgREST; the CHECK only
  proves both signals exist, and the 15-minute TTL lives in the server's
  conditional activation update. The server-only trigger now also refuses
  any end-user transition into 'active' (insert or update). Leaving
  'active' (disconnect, supersede, revoked-key marking) stays
  member-writable. pg-real covers an expired, fully staged row: 42501 from
  a user session, then a member disconnect after the server activates.
- Superagent P2: the VALIDATE CONSTRAINT now runs after the trigger is
  installed, so the gate is never enforced while its signal is still
  member-writable.

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 15:54:28 +02:00

188 lines
7.8 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'
export function buildAuthorizeUrl(storeUrl: string, state: 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: `${baseUrl}/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),
}
}