@salesforce/commerce-sdk-react 5.4.0-preview.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,5 @@
1
- ## v5.4.0-preview.0 (Aug 07, 2026)
1
+ ## v5.4.0 (Aug 12, 2026)
2
+ - [Bugfix] Fix Trusted Agent (Order on Behalf) login hanging on a blank popup because `authorizeTrustedAgent` never sent an OAuth `state`. The storefront recognises the trusted agent callback by the presence of `state`: `request-processor.js` only keeps `code` on `/callback` (and `ssr.js` only serves that variant `no-store` and renders it) when `state` is set, and the callback page only hands `code`+`state` back to the opener when both are present. Without a `state` on the authorize request, SLAS redirected the popup to `/callback` with `code` only, the code was stripped as a standard-login redirect, an empty cacheable body was served, and the popup stayed blank with the login spinner never resolving. `authorizeTrustedAgent` now generates a CSRF `state` (a nonce distinct from the PKCE code verifier), sends it on the authorize request (SLAS echoes it back on the redirect), and returns it; `useTrustedAgent` compares the popup-echoed `state` against the one it minted before exchanging the code, and SLAS additionally binds `state`↔`code` on the token request.
2
3
  - [Bugfix] Fix Trusted Agent (Order on Behalf) login failing when the storefront sends a `Cross-Origin-Opener-Policy: same-origin` header. That header severs the authentication popup so the opener can no longer read the popup location and `popup.closed` wrongly reports `true`, which made the flow reject with "Popup closed without authenticating." before the agent finished. `useTrustedAgent` now receives the result out of band via `postMessage` from the same origin callback page with a `BroadcastChannel` fallback, and it no longer treats a severed `popup.closed` as user cancellation (a genuinely abandoned popup is still caught by the existing timeout). Exposes a `useTrustedAgentPopupCallback` hook for the same origin callback page to hand the OAuth result back to the opener. The hook owns the message contract and the delivery. After delivering the result the callback page also closes itself, since the opener's `popup.close()` is unreliable through the COOP-severed window reference. Additive to the public API. Adds `TRUSTED_AGENT_RUNBOOK.md` documenting the callback contract, the upgrade steps, and how to reproduce and verify the flow.
3
4
  - [Bugfix] Gracefully handle stale or malformed session tokens on load. A truncated `cc-at` cookie chunk or a value left by an older token format could be handed to `jwt-decode`, surfacing an `Invalid token specified: missing part #2` error to the storefront during `ready()`. The auth module now decodes such tokens defensively: an undecodable access token is discarded (and its cookie cleared) and treated as expired, an undecodable SFRA `cc-at` handoff token is cleared with a fallback to the local store, and a malformed `fetchedToken` is ignored — in every case the flow falls through to a refresh / guest login instead of throwing. Only affects non-HttpOnly / SSR / hybrid mode.
4
5
 
package/auth/index.d.ts CHANGED
@@ -410,6 +410,7 @@ declare class Auth {
410
410
  }): Promise<{
411
411
  url: string;
412
412
  codeVerifier: string;
413
+ state: string;
413
414
  }>;
414
415
  /**
415
416
  * Trusted agent login
package/auth/index.js CHANGED
@@ -1181,10 +1181,26 @@ class Auth {
1181
1181
  const loginId = credentials.loginId || 'guest';
1182
1182
  const isGuest = loginId === 'guest';
1183
1183
  const idpOrigin = isGuest ? 'slas' : 'ecom';
1184
- const url = `${slasClient.clientConfig.proxy || ''}/shopper/auth/v1/organizations/${organizationId}/oauth2/trusted-agent/authorize?${[...[`client_id=${clientId}`, `channel_id=${siteId}`, `login_id=${loginId}`, `redirect_uri=${_this9.redirectURI}`, `idp_origin=${idpOrigin}`, `response_type=code`], ...(!_this9.clientSecret ? [`code_challenge=${codeChallenge}`] : [])].join('&')}`;
1184
+ // CSRF `state` for the OAuth authorize request. SLAS echoes it back on the
1185
+ // final redirect to `/callback`, and the storefront relies on its presence:
1186
+ // request-processor keeps `code` (and serves that variant no-store) only when
1187
+ // `state` is set, and the callback page hands `code`+`state` back to the opener
1188
+ // to finish login. Without it the redirect lands with `code` only, the code is
1189
+ // stripped as a standard-login redirect, and the popup hangs. So it must be sent
1190
+ // on every trusted agent authorize request.
1191
+ //
1192
+ // NOTE: this is a distinct nonce from the PKCE `codeVerifier` above and must NOT
1193
+ // be collapsed into it. `createCodeVerifier` is reused only as a random
1194
+ // high-entropy string generator; the two values serve different security roles
1195
+ // (PKCE proof-of-possession vs. OAuth CSRF `state`) and are compared/validated on
1196
+ // separate requests. The caller re-verifies the echoed `state` matches this value
1197
+ // before exchanging the code, and SLAS also binds `state`↔`code` on the token request.
1198
+ const state = _commerceSdkIsomorphic.helpers.createCodeVerifier();
1199
+ const url = `${slasClient.clientConfig.proxy || ''}/shopper/auth/v1/organizations/${organizationId}/oauth2/trusted-agent/authorize?${[...[`client_id=${clientId}`, `channel_id=${siteId}`, `login_id=${loginId}`, `redirect_uri=${_this9.redirectURI}`, `idp_origin=${idpOrigin}`, `response_type=code`, `state=${state}`], ...(!_this9.clientSecret ? [`code_challenge=${codeChallenge}`] : [])].join('&')}`;
1185
1200
  return {
1186
1201
  url,
1187
- codeVerifier
1202
+ codeVerifier,
1203
+ state
1188
1204
  };
1189
1205
  })();
1190
1206
  }
@@ -357,7 +357,8 @@ const useTrustedAgent = () => {
357
357
  }
358
358
  const {
359
359
  url,
360
- codeVerifier
360
+ codeVerifier,
361
+ state: expectedState
361
362
  } = yield authorizeTrustedAgent.mutateAsync({
362
363
  loginId
363
364
  });
@@ -365,6 +366,13 @@ const useTrustedAgent = () => {
365
366
  code,
366
367
  state
367
368
  } = yield createTrustedAgentPopup(url, refresh);
369
+ // CSRF check: the `state` echoed back through the popup must match the one we
370
+ // minted for this authorize request. SLAS also binds `state`↔`code` on the
371
+ // token request below, but comparing here fails fast and stops a mismatched
372
+ // code from ever being exchanged.
373
+ if (state !== expectedState) {
374
+ throw new Error('Trusted agent login failed: state mismatch on authentication callback.');
375
+ }
368
376
  return yield loginTrustedAgent.mutateAsync({
369
377
  loginId,
370
378
  code,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/commerce-sdk-react",
3
- "version": "5.4.0-preview.0",
3
+ "version": "5.4.0",
4
4
  "description": "A library that provides react hooks for fetching data from Commerce Cloud",
5
5
  "homepage": "https://github.com/SalesforceCommerceCloud/pwa-kit/tree/develop/packages/ecom-react-hooks#readme",
6
6
  "bugs": {
@@ -46,7 +46,7 @@
46
46
  "jwt-decode": "^4.0.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@salesforce/pwa-kit-dev": "3.20.0-preview.0",
49
+ "@salesforce/pwa-kit-dev": "3.20.0",
50
50
  "@tanstack/react-query": "^4.28.0",
51
51
  "@testing-library/jest-dom": "^5.16.5",
52
52
  "@testing-library/react": "^14.0.0",
@@ -61,7 +61,7 @@
61
61
  "@types/react-helmet": "~6.1.6",
62
62
  "@types/react-router-dom": "~5.3.3",
63
63
  "cross-env": "^5.2.1",
64
- "internal-lib-build": "3.20.0-preview.0",
64
+ "internal-lib-build": "3.20.0",
65
65
  "jsonwebtoken": "^9.0.0",
66
66
  "nock": "^13.3.0",
67
67
  "nodemon": "^2.0.22",
@@ -98,5 +98,5 @@
98
98
  "publishConfig": {
99
99
  "directory": "dist"
100
100
  },
101
- "gitHead": "63b3848e2c46b8e1ca540f8c80e7f9d96a4a7cb9"
101
+ "gitHead": "bb0a49284fed371ae582ced1268991a843f33be4"
102
102
  }