@salesforce/commerce-sdk-react 5.2.0-nightly-20260501083602 → 5.2.0-preview.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,8 +1,6 @@
1
- ## v5.2.0-nightly-20260501083602 (May 01, 2026)
2
- ## v5.2.0-dev (May 01, 2026)
3
- ## v3.18.0-nightly-20260501083602 (May 01, 2026)
4
- ## v5.2.0-dev (Mar 20, 2026)
1
+ ## v5.2.0-preview.0 (May 01, 2026)
5
2
  - Allow auth related cookies domain to be set via config [#3782](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3782)
3
+ - Add support for HttpOnly session cookies [#3804](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3804)
6
4
 
7
5
  ## v5.1.1 (Mar 20, 2026)
8
6
  - Update storefront preview to support base paths [#3614](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3614)
package/auth/index.d.ts CHANGED
@@ -9,6 +9,7 @@ interface AuthConfig extends ApiClientConfigParams {
9
9
  proxy: string;
10
10
  headers?: Record<string, string>;
11
11
  privateClientProxyEndpoint?: string;
12
+ publicClientProxyEndpoint?: string;
12
13
  fetchOptions?: FetchOptions;
13
14
  fetchedToken?: string;
14
15
  enablePWAKitPrivateClient?: boolean;
@@ -21,6 +22,8 @@ interface AuthConfig extends ApiClientConfigParams {
21
22
  refreshTokenGuestCookieTTL?: number;
22
23
  hybridAuthEnabled?: boolean;
23
24
  cookieDomain?: string;
25
+ /** When true, session tokens are set as HttpOnly cookies */
26
+ enableHttpOnlySessionCookies?: boolean;
24
27
  }
25
28
  /**
26
29
  * Body type for loginRegisteredUserB2C - aligns with register function pattern
@@ -70,12 +73,20 @@ export type AuthData = Prettify<RemoveStringIndex<TokenResponse> & {
70
73
  idp_access_token: string;
71
74
  }>;
72
75
  /** A shopper could be guest or registered, so we store the refresh tokens individually. */
73
- type AuthDataKeys = Exclude<keyof AuthData, 'refresh_token'> | 'refresh_token_guest' | 'refresh_token_registered' | 'access_token_sfra' | typeof DNT_COOKIE_NAME | typeof DWSID_COOKIE_NAME | 'code_verifier' | 'uido' | 'idp_refresh_token' | 'dnt';
76
+ type AuthDataKeys = Exclude<keyof AuthData, 'refresh_token'> | 'refresh_token_guest' | 'refresh_token_registered' | 'access_token_sfra' | typeof DNT_COOKIE_NAME | typeof DWSID_COOKIE_NAME | 'code_verifier' | 'uido' | 'idp_refresh_token' | 'dnt' | 'cc-at-expires' | 'cc-at-dnt' | 'cc-nx-exists';
74
77
  type DntOptions = {
75
78
  includeDefaults: boolean;
76
79
  };
77
80
  export declare const DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL: number;
78
81
  export declare const DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL: number;
82
+ /**
83
+ * Module-level map for deduplicating concurrent refresh token requests across Auth instances.
84
+ * React may recreate Auth instances on re-renders (due to unstable useMemo deps like `headers`),
85
+ * so instance-level dedup via `this.pendingToken` is insufficient. This map ensures only one
86
+ * in-flight refresh request exists per siteId+clientId combination.
87
+ * @internal — exported for test access only
88
+ */
89
+ export declare const pendingRefreshTokens: Map<string, Promise<AuthData>>;
79
90
  /**
80
91
  * This class is used to handle shopper authentication.
81
92
  * It is responsible for initializing shopper session, manage access
@@ -88,7 +99,6 @@ declare class Auth {
88
99
  private client;
89
100
  private shopperCustomersClient;
90
101
  private redirectURI;
91
- private pendingToken;
92
102
  private stores;
93
103
  private fetchedToken;
94
104
  private clientSecret;
@@ -101,10 +111,17 @@ declare class Auth {
101
111
  private refreshTokenGuestCookieTTL;
102
112
  private refreshTrustedAgentHandler;
103
113
  private hybridAuthEnabled;
114
+ private enableHttpOnlySessionCookies;
104
115
  constructor(config: AuthConfig);
105
116
  get(name: AuthDataKeys): string;
106
117
  private set;
107
118
  private delete;
119
+ /**
120
+ * Returns the DNT value from the current access token, or undefined if
121
+ * no access token is available. In HttpOnly mode, reads from the
122
+ * cc-at-dnt companion cookie; otherwise parses the JWT directly.
123
+ */
124
+ private getDntFromAccessToken;
108
125
  /**
109
126
  * Return the value of the DNT cookie or undefined if it is not set.
110
127
  * The DNT cookie being undefined means that there is a necessity to
@@ -129,6 +146,27 @@ declare class Auth {
129
146
  * Used to validate JWT token expiration.
130
147
  */
131
148
  private isTokenExpired;
149
+ /**
150
+ * Returns whether a refresh token exists in an HttpOnly cookie. Since JavaScript cannot
151
+ * read HttpOnly cookies, we check the non-HttpOnly indicator cookie (cc-nx-exists) that
152
+ * is set alongside the refresh token with the same expiry.
153
+ */
154
+ private hasHttpOnlyRefreshToken;
155
+ /**
156
+ * Clears the non-HttpOnly access token expiry cookie (cc-at-expires).
157
+ *
158
+ * This is needed when SCAPI returns a 401 because the HttpOnly access token cookie
159
+ * (cc-at_{siteId}) was deleted externally while the expiry cookie remained valid.
160
+ * Clearing the expiry cookie ensures isAccessTokenExpired() returns true, so
161
+ * subsequent calls to ready() will trigger a refresh instead of assuming the token
162
+ * is still valid.
163
+ */
164
+ clearAccessTokenExpiry(): void;
165
+ /**
166
+ * Returns whether the access token is expired. When enableHttpOnlySessionCookies is true,
167
+ * uses cc-at-expires cookie from store; otherwise decodes the JWT from getAccessToken().
168
+ */
169
+ private isAccessTokenExpired;
132
170
  /**
133
171
  * Returns the SLAS access token or an empty string if the access token
134
172
  * is not found in local store or if SFRA wants PWA to trigger refresh token login.
@@ -186,45 +224,13 @@ declare class Auth {
186
224
  * store the data in storage.
187
225
  */
188
226
  private handleTokenResponse;
189
- refreshAccessToken(): Promise<{
190
- access_token: string;
191
- id_token: string;
192
- refresh_token: string;
193
- expires_in: number;
194
- refresh_token_expires_in: number;
195
- token_type: "Bearer";
196
- usid: string;
197
- customer_id: string;
198
- enc_user_id: string;
199
- idp_access_token: string;
200
- idp_refresh_token?: string | undefined;
201
- dnt?: string | undefined;
202
- } & {
203
- [key: string]: any;
204
- }>;
227
+ private get refreshDedupKey();
228
+ refreshAccessToken(): Promise<AuthData>;
205
229
  /**
206
- * This method queues the requests and handles the SLAS token response.
207
- *
208
- * It returns the queue.
209
- *
210
- * @Internal
230
+ * Internal implementation of the refresh flow. Called only via refreshAccessToken()
231
+ * which wraps it in the module-level pendingRefreshTokens map for deduplication.
211
232
  */
212
- queueRequest(fn: () => Promise<TokenResponse>, isGuest: boolean): Promise<{
213
- access_token: string;
214
- id_token: string;
215
- refresh_token: string;
216
- expires_in: number;
217
- refresh_token_expires_in: number;
218
- token_type: "Bearer";
219
- usid: string;
220
- customer_id: string;
221
- enc_user_id: string;
222
- idp_access_token: string;
223
- idp_refresh_token?: string | undefined;
224
- dnt?: string | undefined;
225
- } & {
226
- [key: string]: any;
227
- }>;
233
+ private _refreshAccessToken;
228
234
  logWarning: (msg: string) => void;
229
235
  /**
230
236
  * This method extracts the status and message from a ResponseError that is returned
@@ -257,22 +263,7 @@ declare class Auth {
257
263
  * 3. If we have valid TAOB access token - refresh TAOB token flow
258
264
  * 4. PKCE flow
259
265
  */
260
- ready(): Promise<{
261
- access_token: string;
262
- id_token: string;
263
- refresh_token: string;
264
- expires_in: number;
265
- refresh_token_expires_in: number;
266
- token_type: "Bearer";
267
- usid: string;
268
- customer_id: string;
269
- enc_user_id: string;
270
- idp_access_token: string;
271
- idp_refresh_token?: string | undefined;
272
- dnt?: string | undefined;
273
- } & {
274
- [key: string]: any;
275
- }>;
266
+ ready(): Promise<AuthData>;
276
267
  /**
277
268
  * Creates a function that only executes after a session is initialized.
278
269
  * @param fn Function that needs to wait until the session is initialized.
@@ -283,22 +274,7 @@ declare class Auth {
283
274
  * A wrapper method for commerce-sdk-isomorphic helper: loginGuestUser.
284
275
  *
285
276
  */
286
- loginGuestUser(parameters?: helpers.CustomQueryParameters): Promise<{
287
- access_token: string;
288
- id_token: string;
289
- refresh_token: string;
290
- expires_in: number;
291
- refresh_token_expires_in: number;
292
- token_type: "Bearer";
293
- usid: string;
294
- customer_id: string;
295
- enc_user_id: string;
296
- idp_access_token: string;
297
- idp_refresh_token?: string | undefined;
298
- dnt?: string | undefined;
299
- } & {
300
- [key: string]: any;
301
- }>;
277
+ loginGuestUser(parameters?: helpers.CustomQueryParameters): Promise<AuthData>;
302
278
  /**
303
279
  * This is a wrapper method for ShopperCustomer API registerCustomer endpoint.
304
280
  *
@@ -333,6 +309,7 @@ declare class Auth {
333
309
  authType?: ("guest" | "registered") | undefined;
334
310
  birthday?: string | undefined;
335
311
  companyName?: string | undefined;
312
+ crmContactId?: string | undefined;
336
313
  creationDate?: string | undefined;
337
314
  currentPassword?: string | undefined;
338
315
  customerId?: string | undefined;
@@ -454,22 +431,7 @@ declare class Auth {
454
431
  * A wrapper method for commerce-sdk-isomorphic helper: logout.
455
432
  *
456
433
  */
457
- logout(): Promise<{
458
- access_token: string;
459
- id_token: string;
460
- refresh_token: string;
461
- expires_in: number;
462
- refresh_token_expires_in: number;
463
- token_type: "Bearer";
464
- usid: string;
465
- customer_id: string;
466
- enc_user_id: string;
467
- idp_access_token: string;
468
- idp_refresh_token?: string | undefined;
469
- dnt?: string | undefined;
470
- } & {
471
- [key: string]: any;
472
- }>;
434
+ logout(): Promise<AuthData>;
473
435
  /**
474
436
  * Handle updating customer password and re-log in after the access token is invalidated.
475
437
  *
@@ -512,6 +474,17 @@ declare class Auth {
512
474
  *
513
475
  */
514
476
  resetPassword(parameters: ShopperLoginTypes.resetPasswordBodyType): Promise<void>;
477
+ /**
478
+ * Get the current USID for Storefront Preview by forcing a SLAS refresh.
479
+ *
480
+ * Works for both guest and registered shoppers: when an existing refresh
481
+ * token is present (guest or registered, legacy or HttpOnly), the SLAS
482
+ * response provides a fresh USID. When no refresh token is present,
483
+ * `refreshAccessToken()` falls through to a guest login, which also yields
484
+ * a fresh USID. Preview can therefore always obtain a USID without
485
+ * requiring the shopper to sign in.
486
+ */
487
+ getUsidForPreview(): Promise<string>;
515
488
  /**
516
489
  * Decode SLAS JWT and extract information such as customer id, usid, etc.
517
490
  *
package/auth/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.default = exports.DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = exports.DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL = void 0;
6
+ exports.pendingRefreshTokens = exports.default = exports.DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = exports.DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL = void 0;
7
7
  var _commerceSdkIsomorphic = require("commerce-sdk-isomorphic");
8
8
  var _jwtDecode = require("jwt-decode");
9
9
  var _storage = require("./storage");
@@ -139,11 +139,32 @@ const DATA_MAP = {
139
139
  uido: {
140
140
  storageType: 'local',
141
141
  key: 'uido'
142
+ },
143
+ 'cc-at-expires': {
144
+ storageType: 'cookie',
145
+ key: 'cc-at-expires'
146
+ },
147
+ 'cc-at-dnt': {
148
+ storageType: 'cookie',
149
+ key: 'cc-at-dnt'
150
+ },
151
+ 'cc-nx-exists': {
152
+ storageType: 'cookie',
153
+ key: 'cc-nx-exists'
142
154
  }
143
155
  };
144
156
  const DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = exports.DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = 90 * 24 * 60 * 60;
145
157
  const DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL = exports.DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL = 30 * 24 * 60 * 60;
146
158
 
159
+ /**
160
+ * Module-level map for deduplicating concurrent refresh token requests across Auth instances.
161
+ * React may recreate Auth instances on re-renders (due to unstable useMemo deps like `headers`),
162
+ * so instance-level dedup via `this.pendingToken` is insufficient. This map ensures only one
163
+ * in-flight refresh request exists per siteId+clientId combination.
164
+ * @internal — exported for test access only
165
+ */
166
+ const pendingRefreshTokens = exports.pendingRefreshTokens = new Map();
167
+
147
168
  /**
148
169
  * This class is used to handle shopper authentication.
149
170
  * It is responsible for initializing shopper session, manage access
@@ -157,7 +178,7 @@ class Auth {
157
178
  // Special proxy endpoint for injecting SLAS private client secret.
158
179
  // We prioritize config.privateClientProxyEndpoint since that allows us to use the new envBasePath feature
159
180
  this.client = new _commerceSdkIsomorphic.ShopperLogin({
160
- proxy: config.enablePWAKitPrivateClient ? config.privateClientProxyEndpoint : config.proxy,
181
+ proxy: config.enablePWAKitPrivateClient ? config.privateClientProxyEndpoint : config.enableHttpOnlySessionCookies ? config.publicClientProxyEndpoint : config.proxy,
161
182
  headers: config.headers || {},
162
183
  parameters: {
163
184
  clientId: config.clientId,
@@ -238,6 +259,7 @@ class Auth {
238
259
  this.isPrivate = !!this.clientSecret;
239
260
  this.passwordlessLoginCallbackURI = config.passwordlessLoginCallbackURI || '';
240
261
  this.hybridAuthEnabled = config.hybridAuthEnabled || false;
262
+ this.enableHttpOnlySessionCookies = config.enableHttpOnlySessionCookies ?? false;
241
263
  }
242
264
  get(name) {
243
265
  const {
@@ -266,6 +288,22 @@ class Auth {
266
288
  storage.delete(key);
267
289
  }
268
290
 
291
+ /**
292
+ * Returns the DNT value from the current access token, or undefined if
293
+ * no access token is available. In HttpOnly mode, reads from the
294
+ * cc-at-dnt companion cookie; otherwise parses the JWT directly.
295
+ */
296
+ getDntFromAccessToken() {
297
+ if (this.enableHttpOnlySessionCookies && (0, _utils.onClient)()) {
298
+ return this.get('cc-at-dnt') || undefined;
299
+ }
300
+ const accessToken = this.getAccessToken();
301
+ if (accessToken) {
302
+ return this.parseSlasJWT(accessToken).dnt;
303
+ }
304
+ return undefined;
305
+ }
306
+
269
307
  /**
270
308
  * Return the value of the DNT cookie or undefined if it is not set.
271
309
  * The DNT cookie being undefined means that there is a necessity to
@@ -282,14 +320,8 @@ class Auth {
282
320
  getDnt(options) {
283
321
  const dntCookieVal = this.get(_constant.DNT_COOKIE_NAME);
284
322
  let dntCookieStatus = undefined;
285
- const accessToken = this.getAccessToken();
286
- let isInSync = true;
287
- if (accessToken) {
288
- const {
289
- dnt
290
- } = this.parseSlasJWT(accessToken);
291
- isInSync = dnt === dntCookieVal;
292
- }
323
+ const accessTokenDnt = this.getDntFromAccessToken();
324
+ const isInSync = accessTokenDnt === undefined || accessTokenDnt === dntCookieVal;
293
325
  if (dntCookieVal !== '1' && dntCookieVal !== '0' || !isInSync) {
294
326
  this.delete(_constant.DNT_COOKIE_NAME);
295
327
  } else {
@@ -322,15 +354,8 @@ class Auth {
322
354
  _this.set(_constant.DNT_COOKIE_NAME, dntCookieVal, _objectSpread(_objectSpread({}, (0, _utils.getDefaultCookieAttributes)()), {}, {
323
355
  secure: true
324
356
  }));
325
- const accessToken = _this.getAccessToken();
326
- if (accessToken !== '') {
327
- const {
328
- dnt
329
- } = _this.parseSlasJWT(accessToken);
330
- if (dnt !== dntCookieVal) {
331
- yield _this.refreshAccessToken();
332
- }
333
- } else {
357
+ const accessTokenDnt = _this.getDntFromAccessToken();
358
+ if (accessTokenDnt === undefined || accessTokenDnt !== dntCookieVal) {
334
359
  yield _this.refreshAccessToken();
335
360
  }
336
361
  if (preference !== null) {
@@ -387,6 +412,46 @@ class Auth {
387
412
  return validTimeSeconds <= tokenAgeSeconds;
388
413
  }
389
414
 
415
+ /**
416
+ * Returns whether a refresh token exists in an HttpOnly cookie. Since JavaScript cannot
417
+ * read HttpOnly cookies, we check the non-HttpOnly indicator cookie (cc-nx-exists) that
418
+ * is set alongside the refresh token with the same expiry.
419
+ */
420
+ hasHttpOnlyRefreshToken() {
421
+ return this.enableHttpOnlySessionCookies && (0, _utils.onClient)() && this.get('cc-nx-exists') === '1';
422
+ }
423
+
424
+ /**
425
+ * Clears the non-HttpOnly access token expiry cookie (cc-at-expires).
426
+ *
427
+ * This is needed when SCAPI returns a 401 because the HttpOnly access token cookie
428
+ * (cc-at_{siteId}) was deleted externally while the expiry cookie remained valid.
429
+ * Clearing the expiry cookie ensures isAccessTokenExpired() returns true, so
430
+ * subsequent calls to ready() will trigger a refresh instead of assuming the token
431
+ * is still valid.
432
+ */
433
+ clearAccessTokenExpiry() {
434
+ this.delete('cc-at-expires');
435
+ }
436
+
437
+ /**
438
+ * Returns whether the access token is expired. When enableHttpOnlySessionCookies is true,
439
+ * uses cc-at-expires cookie from store; otherwise decodes the JWT from getAccessToken().
440
+ */
441
+ isAccessTokenExpired() {
442
+ if (this.enableHttpOnlySessionCookies && (0, _utils.onClient)()) {
443
+ const expiresAt = this.get('cc-at-expires');
444
+ if (expiresAt == null || expiresAt === '') return true;
445
+ const expiresAtSec = Number(expiresAt);
446
+ if (Number.isNaN(expiresAtSec)) return true;
447
+ const bufferSeconds = 60;
448
+ return Date.now() / 1000 >= expiresAtSec - bufferSeconds;
449
+ }
450
+ // Server (SSR) or httpOnly disabled: decode JWT from stored token
451
+ const token = this.getAccessToken();
452
+ return !token || this.isTokenExpired(token);
453
+ }
454
+
390
455
  /**
391
456
  * Returns the SLAS access token or an empty string if the access token
392
457
  * is not found in local store or if SFRA wants PWA to trigger refresh token login.
@@ -543,79 +608,144 @@ class Auth {
543
608
  handleTokenResponse(res, isGuest) {
544
609
  // Delete the SFRA auth token cookie if it exists
545
610
  this.clearSFRAAuthToken();
546
- this.set('access_token', res.access_token);
547
611
  this.set('customer_id', res.customer_id);
548
612
  this.set('enc_user_id', res.enc_user_id);
549
613
  this.set('expires_in', `${res.expires_in}`);
550
614
  this.set('id_token', res.id_token);
551
- this.set('idp_access_token', res.idp_access_token);
552
615
  this.set('token_type', res.token_type);
553
616
  this.set('customer_type', isGuest ? 'guest' : 'registered');
554
- const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered';
555
617
  const refreshTokenTTLValue = this.getRefreshTokenCookieTTLValue(res.refresh_token_expires_in, isGuest);
556
- if (res.access_token) {
557
- const {
558
- uido
559
- } = this.parseSlasJWT(res.access_token);
560
- this.set('uido', uido);
561
- }
562
- const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue);
563
618
  this.set('refresh_token_expires_in', refreshTokenTTLValue.toString());
564
- this.set(refreshTokenKey, res.refresh_token, {
565
- expires: expiresDate
566
- });
567
- this.set('usid', res.usid, {
619
+ const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue);
620
+ this.set('usid', res.usid ?? '', {
568
621
  expires: expiresDate
569
622
  });
623
+ if (this.enableHttpOnlySessionCookies && (0, _utils.onClient)()) {
624
+ // Browser: skip token storage, httpOnly cookies handle it
625
+ const uidoFromCookie = this.stores['cookie'].get('uido');
626
+ if (uidoFromCookie) this.set('uido', uidoFromCookie);
627
+ } else {
628
+ // Server (SSR) or httpOnly disabled: store tokens normally
629
+ this.set('access_token', res.access_token);
630
+ this.set('idp_access_token', res.idp_access_token);
631
+ if (res.access_token) {
632
+ const {
633
+ uido
634
+ } = this.parseSlasJWT(res.access_token);
635
+ this.set('uido', uido);
636
+ }
637
+ const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered';
638
+ this.set(refreshTokenKey, res.refresh_token, {
639
+ expires: expiresDate
640
+ });
641
+ }
642
+ }
643
+ get refreshDedupKey() {
644
+ const params = this.client.clientConfig.parameters;
645
+ return `refresh:${params.siteId}:${params.clientId}`;
570
646
  }
571
647
  refreshAccessToken() {
572
648
  var _this2 = this;
573
649
  return _asyncToGenerator(function* () {
574
- const dntPref = _this2.getDnt({
650
+ // Dedup uses a module-level map (not an instance field) because React may recreate
651
+ // the Auth instance on re-renders, giving each instance its own state. The map is
652
+ // keyed by siteId+clientId so different sites/clients remain independent.
653
+ // On the server (SSR), each request is isolated — skip dedup entirely.
654
+ if (!(0, _utils.onClient)()) {
655
+ return yield _this2._refreshAccessToken();
656
+ }
657
+ const key = _this2.refreshDedupKey;
658
+ const existing = pendingRefreshTokens.get(key);
659
+ if (existing) {
660
+ yield existing;
661
+ return _this2.data;
662
+ }
663
+ const promise = _this2._refreshAccessToken().finally(() => {
664
+ pendingRefreshTokens.delete(key);
665
+ });
666
+ pendingRefreshTokens.set(key, promise);
667
+ return yield promise;
668
+ })();
669
+ }
670
+
671
+ /**
672
+ * Internal implementation of the refresh flow. Called only via refreshAccessToken()
673
+ * which wraps it in the module-level pendingRefreshTokens map for deduplication.
674
+ */
675
+ _refreshAccessToken() {
676
+ var _this3 = this;
677
+ return _asyncToGenerator(function* () {
678
+ const dntPref = _this3.getDnt({
575
679
  includeDefaults: true
576
680
  });
577
- const refreshTokenRegistered = _this2.get('refresh_token_registered');
578
- const refreshTokenGuest = _this2.get('refresh_token_guest');
681
+ const refreshTokenRegistered = _this3.get('refresh_token_registered');
682
+ const refreshTokenGuest = _this3.get('refresh_token_guest');
579
683
  const refreshToken = refreshTokenRegistered || refreshTokenGuest;
580
- if (refreshToken) {
684
+
685
+ // When HttpOnly session cookies are enabled on the client, the refresh token is in an
686
+ // HttpOnly cookie that JavaScript cannot read. We check the non-HttpOnly indicator
687
+ // cookie (cc-nx-exists) to avoid a wasted round-trip when the refresh token is absent.
688
+ // If cc-nx-exists is also missing (e.g. cleared by the user), the proxy layer will
689
+ // catch the missing refresh token and return a 401, falling through to guest login.
690
+ if (refreshToken || !refreshToken && _this3.hasHttpOnlyRefreshToken()) {
581
691
  try {
582
- return yield _this2.queueRequest(() => _commerceSdkIsomorphic.helpers.refreshAccessToken({
583
- slasClient: _this2.client,
692
+ const isGuest = _this3.get('customer_type') !== 'registered';
693
+ // Signal the proxy that this is a refresh token request so it can
694
+ // inject the HttpOnly refresh token cookie as the sfdc_refresh_token header.
695
+ if (_this3.enableHttpOnlySessionCookies) {
696
+ _this3.client.clientConfig.headers[_constant.X_GRANT_TYPE] = 'refresh_token';
697
+ }
698
+ const token = yield _commerceSdkIsomorphic.helpers.refreshAccessToken({
699
+ slasClient: _this3.client,
584
700
  parameters: {
585
- refreshToken,
701
+ refreshToken: refreshToken || '',
586
702
  dnt: dntPref
587
703
  },
588
704
  credentials: {
589
- clientSecret: _this2.clientSecret
590
- }
591
- }), !!refreshTokenGuest);
705
+ clientSecret: _this3.clientSecret
706
+ },
707
+ enableHttpOnlySessionCookies: _this3.enableHttpOnlySessionCookies
708
+ });
709
+ _this3.handleTokenResponse(token, isGuest);
710
+ return _this3.data;
592
711
  } catch (error) {
593
- // If the refresh token is invalid, we need to re-login the user
712
+ // If the refresh token is invalid, we need to re-login the user.
594
713
  if (error instanceof Error && 'response' in error) {
595
714
  // commerce-sdk-isomorphic throws a `ResponseError`, but doesn't export the class.
596
715
  // We can't use `instanceof`, so instead we just check for the `response` property
597
716
  // and assume it is a fetch Response.
598
717
  const json = yield error['response'].json();
599
718
  if (json.message === 'invalid refresh_token') {
600
- // clean up storage and restart the login flow
601
- _this2.clearStorage();
719
+ // In a multi-tab scenario, another tab may have already consumed the
720
+ // one-time-use refresh token and stored fresh tokens. Re-check storage
721
+ // before clearing — if a valid access token exists, use it instead of
722
+ // wiping the other tab's work and falling back to guest login.
723
+ if (!_this3.isAccessTokenExpired()) {
724
+ return _this3.data;
725
+ }
726
+ // No valid token found — clean up storage and restart the login flow.
727
+ _this3.clearStorage();
602
728
  }
603
729
  }
730
+ } finally {
731
+ delete _this3.client.clientConfig.headers[_constant.X_GRANT_TYPE];
604
732
  }
605
733
  }
606
734
 
607
735
  // refresh flow for TAOB
608
- const accessToken = _this2.getAccessToken();
609
- if (accessToken && _this2.isTokenExpired(accessToken)) {
736
+ const accessToken = _this3.getAccessToken();
737
+ if (_this3.isAccessTokenExpired()) {
610
738
  try {
611
739
  const {
612
740
  isGuest,
613
741
  usid,
614
742
  loginId,
615
743
  isAgent
616
- } = _this2.parseSlasJWT(accessToken);
744
+ } = _this3.parseSlasJWT(accessToken);
617
745
  if (isAgent) {
618
- return yield _this2.queueRequest(() => _this2.refreshTrustedAgent(loginId, usid), isGuest);
746
+ const token = yield _this3.refreshTrustedAgent(loginId, usid);
747
+ _this3.handleTokenResponse(token, isGuest);
748
+ return _this3.data;
619
749
  }
620
750
  } catch (e) {
621
751
  /* catch invalid jwt */
@@ -626,39 +756,14 @@ class Auth {
626
756
  // use it, we will be stuck in a fail loop
627
757
  let token;
628
758
  try {
629
- token = yield _this2.loginGuestUser();
759
+ token = yield _this3.loginGuestUser();
630
760
  } catch (e) {
631
- _this2.clearStorage();
632
- token = yield _this2.loginGuestUser();
761
+ _this3.clearStorage();
762
+ token = yield _this3.loginGuestUser();
633
763
  }
634
764
  return token;
635
765
  })();
636
766
  }
637
-
638
- /**
639
- * This method queues the requests and handles the SLAS token response.
640
- *
641
- * It returns the queue.
642
- *
643
- * @Internal
644
- */
645
- queueRequest(fn, isGuest) {
646
- var _this3 = this;
647
- return _asyncToGenerator(function* () {
648
- const queue = _this3.pendingToken ?? Promise.resolve();
649
- _this3.pendingToken = queue.then(/*#__PURE__*/_asyncToGenerator(function* () {
650
- const token = yield fn();
651
- _this3.handleTokenResponse(token, isGuest);
652
- // Q: Why don't we just return token? Why re-construct the same object again?
653
- // A: because a user could open multiple tabs and the data in memory could be out-dated
654
- // We must always grab the data from the storage (cookie/localstorage) directly
655
- return _this3.data;
656
- })).finally(() => {
657
- _this3.pendingToken = undefined;
658
- });
659
- return yield _this3.pendingToken;
660
- })();
661
- }
662
767
  logWarning = msg => {
663
768
  if (!this.silenceWarnings) {
664
769
  this.logger.warn(msg);
@@ -681,7 +786,7 @@ class Auth {
681
786
  * @Internal
682
787
  */
683
788
  extractResponseError = (() => function () {
684
- var _ref2 = _asyncToGenerator(function* (error) {
789
+ var _ref = _asyncToGenerator(function* (error) {
685
790
  // the regular error.message will return only the generic status code message
686
791
  // ie. 'Bad Request' for 400. We need to drill specifically into the ResponseError
687
792
  // to get a more descriptive error message from SLAS
@@ -697,7 +802,7 @@ class Auth {
697
802
  throw error;
698
803
  });
699
804
  return function (_x) {
700
- return _ref2.apply(this, arguments);
805
+ return _ref.apply(this, arguments);
701
806
  };
702
807
  }())();
703
808
 
@@ -751,11 +856,14 @@ class Auth {
751
856
  _this4.set('customer_type', isGuest ? 'guest' : 'registered');
752
857
  return _this4.data;
753
858
  }
754
- if (_this4.pendingToken) {
755
- return yield _this4.pendingToken;
859
+ if ((0, _utils.onClient)()) {
860
+ const pendingRefresh = pendingRefreshTokens.get(_this4.refreshDedupKey);
861
+ if (pendingRefresh) {
862
+ yield pendingRefresh;
863
+ return _this4.data;
864
+ }
756
865
  }
757
- const accessToken = _this4.getAccessToken();
758
- if (accessToken && !_this4.isTokenExpired(accessToken)) {
866
+ if (!_this4.isAccessTokenExpired()) {
759
867
  return _this4.data;
760
868
  }
761
869
  return yield _this4.refreshAccessToken();
@@ -810,9 +918,16 @@ class Auth {
810
918
  usid
811
919
  }), parameters)
812
920
  };
813
- const callback = _this6.clientSecret ? () => _commerceSdkIsomorphic.helpers.loginGuestUserPrivate(_objectSpread({}, guestPrivateArgs)) : () => _commerceSdkIsomorphic.helpers.loginGuestUser(_objectSpread({}, guestPublicArgs));
921
+ const enableHttpOnlySessionCookies = _this6.enableHttpOnlySessionCookies;
922
+ const callback = _this6.clientSecret ? () => _commerceSdkIsomorphic.helpers.loginGuestUserPrivate(_objectSpread(_objectSpread({}, guestPrivateArgs), {}, {
923
+ enableHttpOnlySessionCookies
924
+ })) : () => _commerceSdkIsomorphic.helpers.loginGuestUser(_objectSpread(_objectSpread({}, guestPublicArgs), {}, {
925
+ enableHttpOnlySessionCookies
926
+ }));
814
927
  try {
815
- return yield _this6.queueRequest(callback, isGuest);
928
+ const token = yield callback();
929
+ _this6.handleTokenResponse(token, isGuest);
930
+ return _this6.data;
816
931
  } catch (error) {
817
932
  // We catch the error here to do logging but we still need to
818
933
  // throw an error to stop the login flow from continuing.
@@ -908,7 +1023,8 @@ class Auth {
908
1023
  }, usid && {
909
1024
  usid
910
1025
  }),
911
- body: customParameters
1026
+ body: customParameters,
1027
+ enableHttpOnlySessionCookies: _this8.enableHttpOnlySessionCookies
912
1028
  };
913
1029
  const token = yield _commerceSdkIsomorphic.helpers.loginRegisteredUserB2C(loginParams);
914
1030
  _this8.handleTokenResponse(token, isGuest);
@@ -1013,14 +1129,23 @@ class Auth {
1013
1129
  var _this10 = this;
1014
1130
  return _asyncToGenerator(function* () {
1015
1131
  if (_this10.get('customer_type') === 'registered') {
1016
- // Not awaiting on purpose because there isn't much we can do if this fails.
1017
- void _commerceSdkIsomorphic.helpers.logout({
1132
+ const logoutPromise = _commerceSdkIsomorphic.helpers.logout({
1018
1133
  slasClient: _this10.client,
1019
1134
  parameters: {
1020
1135
  accessToken: _this10.get('access_token'),
1021
1136
  refreshToken: _this10.get('refresh_token_registered')
1022
1137
  }
1023
1138
  });
1139
+ if (_this10.enableHttpOnlySessionCookies) {
1140
+ // When HttpOnly cookies are enabled, the proxy expires session cookies
1141
+ // on the logout response. We must await so the browser processes the
1142
+ // Set-Cookie headers before guest login sets new cookies.
1143
+ try {
1144
+ yield logoutPromise;
1145
+ } catch (error) {
1146
+ _this10.logger.warn(`SLAS logout failed: ${error instanceof Error ? error.message : String(error)}. The error is ignored and session cookies are still cleared by the proxy.`);
1147
+ }
1148
+ }
1024
1149
  }
1025
1150
  _this10.clearStorage();
1026
1151
  return yield _this10.ready();
@@ -1142,7 +1267,8 @@ class Auth {
1142
1267
  dnt: dntPref
1143
1268
  }, usid && {
1144
1269
  usid
1145
- })
1270
+ }),
1271
+ enableHttpOnlySessionCookies: _this13.enableHttpOnlySessionCookies
1146
1272
  });
1147
1273
  const isGuest = false;
1148
1274
  _this13.handleTokenResponse(token, isGuest);
@@ -1223,7 +1349,8 @@ class Auth {
1223
1349
  usid
1224
1350
  }), parameters.register_customer !== undefined && {
1225
1351
  register_customer: typeof parameters.register_customer === 'boolean' ? String(parameters.register_customer) : parameters.register_customer
1226
- })
1352
+ }),
1353
+ enableHttpOnlySessionCookies: _this15.enableHttpOnlySessionCookies
1227
1354
  });
1228
1355
  const isGuest = false;
1229
1356
  _this15.handleTokenResponse(token, isGuest);
@@ -1313,6 +1440,28 @@ class Auth {
1313
1440
  })();
1314
1441
  }
1315
1442
 
1443
+ /**
1444
+ * Get the current USID for Storefront Preview by forcing a SLAS refresh.
1445
+ *
1446
+ * Works for both guest and registered shoppers: when an existing refresh
1447
+ * token is present (guest or registered, legacy or HttpOnly), the SLAS
1448
+ * response provides a fresh USID. When no refresh token is present,
1449
+ * `refreshAccessToken()` falls through to a guest login, which also yields
1450
+ * a fresh USID. Preview can therefore always obtain a USID without
1451
+ * requiring the shopper to sign in.
1452
+ */
1453
+ getUsidForPreview() {
1454
+ var _this18 = this;
1455
+ return _asyncToGenerator(function* () {
1456
+ yield _this18.refreshAccessToken();
1457
+ const usid = _this18.get('usid');
1458
+ if (!usid) {
1459
+ throw new Error('SLAS refresh did not return a USID');
1460
+ }
1461
+ return usid;
1462
+ })();
1463
+ }
1464
+
1316
1465
  /**
1317
1466
  * Decode SLAS JWT and extract information such as customer id, usid, etc.
1318
1467
  *
@@ -35,8 +35,8 @@ const PWA_KIT_PATH_PREFIX = '/__pwa-kit/';
35
35
  /**
36
36
  * Runtime Admin always prepends envBasePath to /__pwa-kit/ paths (e.g. /test/__pwa-kit/refresh),
37
37
  * but when showBasePath is false, React Router has no basename and expects /__pwa-kit/refresh.
38
- *
39
- * This ensures that regardless of the showBasePath setting, these paths are normalized to
38
+ *
39
+ * This ensures that regardless of the showBasePath setting, these paths are normalized to
40
40
  * remove the base path.
41
41
  */
42
42
  function normalizePwaKitPath(pathOrLocation) {
@@ -92,10 +92,14 @@ const StorefrontPreview = ({
92
92
  const {
93
93
  siteId
94
94
  } = (0, _hooks.useConfig)();
95
+ const {
96
+ getUsidForPreview
97
+ } = (0, _hooks.useUsid)();
95
98
  (0, _react.useEffect)(() => {
96
99
  if (enabled && isHostTrusted) {
97
100
  window.STOREFRONT_PREVIEW = _objectSpread(_objectSpread({}, window.STOREFRONT_PREVIEW), {}, {
98
101
  getToken,
102
+ getUsid: getUsidForPreview,
99
103
  onContextChange,
100
104
  siteId,
101
105
  experimentalUnsafeNavigate: (path, action = 'push', ...args) => {
@@ -106,7 +110,7 @@ const StorefrontPreview = ({
106
110
  }
107
111
  });
108
112
  }
109
- }, [enabled, getToken, onContextChange, siteId, getBasePath]);
113
+ }, [enabled, getToken, getUsidForPreview, onContextChange, siteId, getBasePath]);
110
114
  (0, _react.useEffect)(() => {
111
115
  if (enabled && isHostTrusted) {
112
116
  // In Storefront Preview mode, add cache breaker for all SCAPI's requests.
package/constant.d.ts CHANGED
@@ -20,6 +20,7 @@ export declare const EXCLUDE_COOKIE_SUFFIX: string[];
20
20
  * Use the header key below to send dwsid value with SCAPI/OCAPI requests.
21
21
  */
22
22
  export declare const SERVER_AFFINITY_HEADER_KEY = "sfdc_dwsid";
23
+ export declare const X_GRANT_TYPE = "x-grant-type";
23
24
  export declare const CLIENT_KEYS: {
24
25
  readonly SHOPPER_BASKETS: "shopperBaskets";
25
26
  readonly SHOPPER_BASKETS_V2: "shopperBasketsV2";
package/constant.js CHANGED
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.SLAS_SECRET_WARNING_MSG = exports.SLAS_SECRET_PLACEHOLDER = exports.SLAS_SECRET_OVERRIDE_MSG = exports.SLAS_REFRESH_TOKEN_COOKIE_TTL_OVERRIDE_MSG = exports.SERVER_AFFINITY_HEADER_KEY = exports.IFRAME_HOST_ALLOW_LIST = exports.EXCLUDE_COOKIE_SUFFIX = exports.DWSID_COOKIE_NAME = exports.DNT_COOKIE_NAME = exports.CLIENT_KEYS = void 0;
6
+ exports.X_GRANT_TYPE = exports.SLAS_SECRET_WARNING_MSG = exports.SLAS_SECRET_PLACEHOLDER = exports.SLAS_SECRET_OVERRIDE_MSG = exports.SLAS_REFRESH_TOKEN_COOKIE_TTL_OVERRIDE_MSG = exports.SERVER_AFFINITY_HEADER_KEY = exports.IFRAME_HOST_ALLOW_LIST = exports.EXCLUDE_COOKIE_SUFFIX = exports.DWSID_COOKIE_NAME = exports.DNT_COOKIE_NAME = exports.CLIENT_KEYS = void 0;
7
7
  /*
8
8
  * Copyright (c) 2025, Salesforce, Inc.
9
9
  * All rights reserved.
@@ -36,6 +36,9 @@ const EXCLUDE_COOKIE_SUFFIX = exports.EXCLUDE_COOKIE_SUFFIX = [DWSID_COOKIE_NAME
36
36
  * Use the header key below to send dwsid value with SCAPI/OCAPI requests.
37
37
  */
38
38
  const SERVER_AFFINITY_HEADER_KEY = exports.SERVER_AFFINITY_HEADER_KEY = 'sfdc_dwsid';
39
+
40
+ // Custom header sent by the SDK to signal a refresh token request to the proxy.
41
+ const X_GRANT_TYPE = exports.X_GRANT_TYPE = 'x-grant-type';
39
42
  const CLIENT_KEYS = exports.CLIENT_KEYS = {
40
43
  SHOPPER_BASKETS: 'shopperBaskets',
41
44
  SHOPPER_BASKETS_V2: 'shopperBasketsV2',
@@ -9,22 +9,7 @@ import { CustomEndpointArg, OptionalCustomEndpointClientConfig, TMutationVariabl
9
9
  * @param error - the error
10
10
  * @returns a new guest access token
11
11
  */
12
- export declare const handleInvalidToken: (error: any, auth: Auth, logger: Logger) => Promise<{
13
- access_token: string;
14
- id_token: string;
15
- refresh_token: string;
16
- expires_in: number;
17
- refresh_token_expires_in: number;
18
- token_type: "Bearer";
19
- usid: string;
20
- customer_id: string;
21
- enc_user_id: string;
22
- idp_access_token: string;
23
- idp_refresh_token?: string | undefined;
24
- dnt?: string | undefined;
25
- } & {
26
- [key: string]: any;
27
- }>;
12
+ export declare const handleInvalidToken: (error: any, auth: Auth, logger: Logger) => Promise<import("../auth").AuthData>;
28
13
  /**
29
14
  * A helper function for preparing a call to the SCAPI custom API endpoint
30
15
  */
package/hooks/helpers.js CHANGED
@@ -4,20 +4,19 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.handleInvalidToken = exports.generateCustomEndpointOptions = void 0;
7
+ var _utils = require("../utils");
7
8
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
8
9
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
9
10
  function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
10
11
  function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
11
12
  function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
12
13
  function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); }
13
- function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; }
14
- /*
14
+ function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; } /*
15
15
  * Copyright (c) 2024, Salesforce, Inc.
16
16
  * All rights reserved.
17
17
  * SPDX-License-Identifier: BSD-3-Clause
18
18
  * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
19
19
  */
20
-
21
20
  /**
22
21
  * A helper function for handling bad responses from SCAPI when an invalid access token is used.
23
22
  *
@@ -27,16 +26,30 @@ function _asyncToGenerator(n) { return function () { var t = this, e = arguments
27
26
  */
28
27
  const handleInvalidToken = exports.handleInvalidToken = /*#__PURE__*/function () {
29
28
  var _ref = _asyncToGenerator(function* (error, auth, logger) {
30
- var _error$response, _error$response2;
31
- if ((error === null || error === void 0 ? void 0 : (_error$response = error.response) === null || _error$response === void 0 ? void 0 : _error$response.status) !== 401) {
32
- throw error;
29
+ var _error$response, _error$response3, _error$response4;
30
+ // The proxy returns a 400 with this message when the HttpOnly access token cookie
31
+ // (cc-at_{siteId}) is missing. This can happen if the cookie was deleted externally
32
+ // (e.g. via dev tools) while the non-HttpOnly expiry cookie (cc-at-expires) remained
33
+ // valid, causing isAccessTokenExpired() to incorrectly report the token as not expired.
34
+ // Clear the stale expiry cookie and trigger a token refresh.
35
+ if ((error === null || error === void 0 ? void 0 : (_error$response = error.response) === null || _error$response === void 0 ? void 0 : _error$response.status) === 400) {
36
+ var _error$response2;
37
+ const response = yield error === null || error === void 0 ? void 0 : (_error$response2 = error.response) === null || _error$response2 === void 0 ? void 0 : _error$response2.json();
38
+ if ((response === null || response === void 0 ? void 0 : response.message) === 'access_token_cookie_missing') {
39
+ logger.warn('Access token cookie missing. Clearing expiry and refreshing token.');
40
+ auth.clearAccessTokenExpiry();
41
+ return yield auth.refreshAccessToken();
42
+ }
33
43
  }
34
- const response = yield error === null || error === void 0 ? void 0 : (_error$response2 = error.response) === null || _error$response2 === void 0 ? void 0 : _error$response2.json();
35
- if ((response === null || response === void 0 ? void 0 : response.detail) !== 'Customer credentials changed after token was issued.') {
44
+ if ((error === null || error === void 0 ? void 0 : (_error$response3 = error.response) === null || _error$response3 === void 0 ? void 0 : _error$response3.status) !== 401) {
36
45
  throw error;
37
46
  }
38
- logger.info('Login was invalidated. Clearing login state.');
39
- return yield auth.logout();
47
+ const response = yield error === null || error === void 0 ? void 0 : (_error$response4 = error.response) === null || _error$response4 === void 0 ? void 0 : _error$response4.json();
48
+ if ((response === null || response === void 0 ? void 0 : response.detail) === 'Customer credentials changed after token was issued.') {
49
+ logger.info('Login was invalidated. Clearing login state.');
50
+ return yield auth.logout();
51
+ }
52
+ throw error;
40
53
  });
41
54
  return function handleInvalidToken(_x, _x2, _x3) {
42
55
  return _ref.apply(this, arguments);
@@ -62,9 +75,9 @@ const generateCustomEndpointOptions = (options, config, access_token, args) => {
62
75
  return _objectSpread(_objectSpread({}, options), {}, {
63
76
  options: _objectSpread(_objectSpread({}, options.options), {}, {
64
77
  method: ((_options$options = options.options) === null || _options$options === void 0 ? void 0 : _options$options.method) || 'GET',
65
- headers: _objectSpread(_objectSpread(_objectSpread({
78
+ headers: _objectSpread(_objectSpread(_objectSpread(_objectSpread({}, config.enableHttpOnlySessionCookies && (0, _utils.onClient)() ? {} : {
66
79
  Authorization: `Bearer ${access_token}`
67
- }, globalHeaders), (_options$options2 = options.options) === null || _options$options2 === void 0 ? void 0 : _options$options2.headers), args !== null && args !== void 0 && args.headers ? args.headers : {})
80
+ }), globalHeaders), (_options$options2 = options.options) === null || _options$options2 === void 0 ? void 0 : _options$options2.headers), args !== null && args !== void 0 && args.headers ? args.headers : {})
68
81
  }),
69
82
  clientConfig: _objectSpread(_objectSpread({}, globalClientConfig), options.clientConfig || {})
70
83
  });
@@ -7,6 +7,7 @@ exports.useAuthorizationHeader = void 0;
7
7
  var _useAuthContext = _interopRequireDefault(require("./useAuthContext"));
8
8
  var _useConfig = _interopRequireDefault(require("./useConfig"));
9
9
  var _helpers = require("./helpers");
10
+ var _utils = require("../utils");
10
11
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
11
12
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
12
13
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
@@ -38,21 +39,25 @@ const useAuthorizationHeader = method => {
38
39
  const {
39
40
  access_token
40
41
  } = yield auth.ready();
42
+ // When HttpOnly session cookies are enabled on the client, the proxy injects the
43
+ // Authorization header from the cookie — skip adding it here.
44
+ const authHeaders = config.enableHttpOnlySessionCookies && (0, _utils.onClient)() ? {} : {
45
+ Authorization: `Bearer ${access_token}`
46
+ };
41
47
  return yield method(_objectSpread(_objectSpread({}, options), {}, {
42
- headers: _objectSpread({
43
- Authorization: `Bearer ${access_token}`
44
- }, options.headers)
48
+ headers: _objectSpread(_objectSpread({}, authHeaders), options.headers)
45
49
  })).catch(/*#__PURE__*/function () {
46
50
  var _ref2 = _asyncToGenerator(function* (error) {
47
51
  const {
48
52
  access_token
49
53
  } = yield (0, _helpers.handleInvalidToken)(error, auth, logger);
54
+ const retryAuthHeaders = config.enableHttpOnlySessionCookies && (0, _utils.onClient)() ? {} : {
55
+ Authorization: `Bearer ${access_token}`
56
+ };
50
57
 
51
58
  // Retry again after resetting auth state
52
59
  return yield method(_objectSpread(_objectSpread({}, options), {}, {
53
- headers: _objectSpread({
54
- Authorization: `Bearer ${access_token}`
55
- }, options.headers)
60
+ headers: _objectSpread(_objectSpread({}, retryAuthHeaders), options.headers)
56
61
  }));
57
62
  });
58
63
  return function (_x2) {
@@ -4,6 +4,7 @@
4
4
  interface Usid {
5
5
  usid: string | null;
6
6
  getUsidWhenReady: () => Promise<string>;
7
+ getUsidForPreview: () => Promise<string>;
7
8
  }
8
9
  /**
9
10
  * Hook that returns the usid associated with the current access token.
package/hooks/useUsid.js CHANGED
@@ -35,9 +35,11 @@ const useUsid = () => {
35
35
  const getUsidWhenReady = () => auth.ready().then(({
36
36
  usid
37
37
  }) => usid);
38
+ const getUsidForPreview = () => auth.getUsidForPreview();
38
39
  return {
39
40
  usid,
40
- getUsidWhenReady
41
+ getUsidWhenReady,
42
+ getUsidForPreview
41
43
  };
42
44
  };
43
45
  var _default = exports.default = useUsid;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/commerce-sdk-react",
3
- "version": "5.2.0-nightly-20260501083602",
3
+ "version": "5.2.0-preview.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": {
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@salesforce/storefront-next-runtime": "0.1.1",
44
- "commerce-sdk-isomorphic": "5.1.0",
44
+ "commerce-sdk-isomorphic": "5.2.0",
45
45
  "js-cookie": "^3.0.1",
46
46
  "jwt-decode": "^4.0.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@salesforce/pwa-kit-dev": "3.18.0-nightly-20260501083602",
49
+ "@salesforce/pwa-kit-dev": "3.18.0-preview.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.18.0-nightly-20260501083602",
64
+ "internal-lib-build": "3.18.0-preview.0",
65
65
  "jsonwebtoken": "^9.0.0",
66
66
  "nock": "^13.3.0",
67
67
  "nodemon": "^2.0.22",
@@ -97,5 +97,5 @@
97
97
  "publishConfig": {
98
98
  "directory": "dist"
99
99
  },
100
- "gitHead": "62e557dc3f3656871255ea02661d681aeff4ec30"
100
+ "gitHead": "75dd6afbf5269009754337c2cddc5bc0c508e3b3"
101
101
  }
package/provider.d.ts CHANGED
@@ -19,6 +19,7 @@ export interface CommerceApiProviderProps extends ApiClientConfigParams {
19
19
  fetchedToken?: string;
20
20
  enablePWAKitPrivateClient?: boolean;
21
21
  privateClientProxyEndpoint?: string;
22
+ publicClientProxyEndpoint?: string;
22
23
  clientSecret?: string;
23
24
  silenceWarnings?: boolean;
24
25
  logger?: Logger;
@@ -31,6 +32,8 @@ export interface CommerceApiProviderProps extends ApiClientConfigParams {
31
32
  hybridAuthEnabled?: boolean;
32
33
  cookieDomain?: string;
33
34
  pageDesignerParams?: PageDesignerParams;
35
+ /** When true, proxy returns tokens in HttpOnly cookies. */
36
+ enableHttpOnlySessionCookies?: boolean;
34
37
  }
35
38
  /**
36
39
  * @internal
package/provider.js CHANGED
@@ -108,6 +108,7 @@ const CommerceApiProvider = props => {
108
108
  fetchedToken,
109
109
  enablePWAKitPrivateClient,
110
110
  privateClientProxyEndpoint,
111
+ publicClientProxyEndpoint,
111
112
  clientSecret,
112
113
  silenceWarnings,
113
114
  logger,
@@ -119,11 +120,32 @@ const CommerceApiProvider = props => {
119
120
  disableAuthInit = false,
120
121
  hybridAuthEnabled = false,
121
122
  cookieDomain,
122
- pageDesignerParams = {}
123
+ pageDesignerParams = {},
124
+ enableHttpOnlySessionCookies = false
123
125
  } = props;
124
126
 
125
127
  // Set the logger based on provided configuration, or default to the console object if no logger is provided
126
128
  const configLogger = logger || console;
129
+
130
+ // Stabilize object references that may be recreated on every render (e.g. inline
131
+ // `headers={{...}}` or `logger={createLogger(...)}` in the parent component).
132
+ // Without this, the Auth useMemo would recreate the Auth instance on every render,
133
+ // causing unnecessary useEffect re-runs, context re-renders, and breaking
134
+ // request deduplication.
135
+ const headersKey = JSON.stringify(headers);
136
+ const stableHeaders = (0, _react.useMemo)(() => headers, [headersKey]);
137
+ const loggerRef = (0, _react.useRef)(configLogger);
138
+ loggerRef.current = configLogger;
139
+ // Logger identity is not meaningful — keep the first instance for reference stability.
140
+ // The ref ensures the Auth instance always calls the latest logger.
141
+ const stableLogger = (0, _react.useMemo)(() => loggerRef.current, []);
142
+
143
+ // When HttpOnly cookies are enabled, ensure fetch credentials allow cookies to be sent.
144
+ const effectiveFetchOptions = (0, _react.useMemo)(() => {
145
+ return enableHttpOnlySessionCookies && (!(fetchOptions !== null && fetchOptions !== void 0 && fetchOptions.credentials) || fetchOptions.credentials === 'omit') ? _objectSpread(_objectSpread({}, fetchOptions), {}, {
146
+ credentials: 'same-origin'
147
+ }) : fetchOptions;
148
+ }, [enableHttpOnlySessionCookies, fetchOptions]);
127
149
  const auth = (0, _react.useMemo)(() => {
128
150
  return new _auth.default({
129
151
  clientId,
@@ -132,22 +154,23 @@ const CommerceApiProvider = props => {
132
154
  siteId,
133
155
  proxy,
134
156
  redirectURI,
135
- headers,
136
- fetchOptions,
157
+ headers: stableHeaders,
158
+ fetchOptions: effectiveFetchOptions,
137
159
  fetchedToken,
138
160
  enablePWAKitPrivateClient,
139
161
  privateClientProxyEndpoint,
162
+ publicClientProxyEndpoint,
140
163
  clientSecret,
141
164
  silenceWarnings,
142
- logger: configLogger,
165
+ logger: stableLogger,
143
166
  defaultDnt,
144
167
  passwordlessLoginCallbackURI,
145
168
  refreshTokenRegisteredCookieTTL,
146
169
  refreshTokenGuestCookieTTL,
147
170
  hybridAuthEnabled,
148
- cookieDomain
171
+ enableHttpOnlySessionCookies
149
172
  });
150
- }, [clientId, organizationId, shortCode, siteId, proxy, redirectURI, headers, fetchOptions, fetchedToken, enablePWAKitPrivateClient, privateClientProxyEndpoint, clientSecret, silenceWarnings, configLogger, defaultDnt, passwordlessLoginCallbackURI, refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL, apiClients, hybridAuthEnabled, cookieDomain]);
173
+ }, [clientId, organizationId, shortCode, siteId, proxy, redirectURI, stableHeaders, effectiveFetchOptions, fetchedToken, enablePWAKitPrivateClient, privateClientProxyEndpoint, publicClientProxyEndpoint, clientSecret, silenceWarnings, stableLogger, defaultDnt, passwordlessLoginCallbackURI, refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL, apiClients, hybridAuthEnabled, cookieDomain, enableHttpOnlySessionCookies]);
151
174
  const dwsid = auth.get(_constant.DWSID_COOKIE_NAME);
152
175
  const serverAffinityHeader = {};
153
176
  if (dwsid) {
@@ -157,7 +180,7 @@ const CommerceApiProvider = props => {
157
180
  return _objectSpread(_objectSpread({}, options), {}, {
158
181
  headers: _objectSpread(_objectSpread({}, options.headers), serverAffinityHeader),
159
182
  throwOnBadResponse: true,
160
- fetchOptions: _objectSpread(_objectSpread({}, options.fetchOptions), fetchOptions)
183
+ fetchOptions: _objectSpread(_objectSpread({}, options.fetchOptions), effectiveFetchOptions)
161
184
  });
162
185
  };
163
186
  const updatedClients = (0, _react.useMemo)(() => {
@@ -192,8 +215,14 @@ const CommerceApiProvider = props => {
192
215
  currency
193
216
  },
194
217
  throwOnBadResponse: true,
195
- fetchOptions
218
+ fetchOptions: effectiveFetchOptions
196
219
  };
220
+
221
+ // Determine the proxy endpoint for ShopperLogin based on the client mode:
222
+ // - Private client mode uses a dedicated private proxy endpoint
223
+ // - HttpOnly session cookies mode uses a public proxy endpoint
224
+ // - Otherwise, fall back to the default proxy
225
+ const shopperLoginProxy = enablePWAKitPrivateClient ? privateClientProxyEndpoint : enableHttpOnlySessionCookies ? publicClientProxyEndpoint : config.proxy;
197
226
  return {
198
227
  shopperBaskets: new _commerceSdkIsomorphic.ShopperBaskets(config),
199
228
  shopperBasketsV2: new _commerceSdkIsomorphic.ShopperBasketsV2(config),
@@ -204,7 +233,7 @@ const CommerceApiProvider = props => {
204
233
  shopperExperience: new _commerceSdkIsomorphic.ShopperExperience(config),
205
234
  shopperGiftCertificates: new _commerceSdkIsomorphic.ShopperGiftCertificates(config),
206
235
  shopperLogin: new _commerceSdkIsomorphic.ShopperLogin(_objectSpread(_objectSpread({}, config), {}, {
207
- proxy: enablePWAKitPrivateClient ? privateClientProxyEndpoint : config.proxy
236
+ proxy: shopperLoginProxy
208
237
  })),
209
238
  shopperOrders: new _commerceSdkIsomorphic.ShopperOrders(config),
210
239
  shopperPayments: new _commerceSdkIsomorphic.ShopperPayments(config),
@@ -214,7 +243,7 @@ const CommerceApiProvider = props => {
214
243
  shopperSeo: new _commerceSdkIsomorphic.ShopperSEO(config),
215
244
  shopperStores: new _commerceSdkIsomorphic.ShopperStores(config)
216
245
  };
217
- }, [clientId, organizationId, shortCode, siteId, proxy, fetchOptions, locale, currency, headers === null || headers === void 0 ? void 0 : headers['correlation-id'], apiClients]);
246
+ }, [clientId, organizationId, shortCode, siteId, proxy, effectiveFetchOptions, locale, currency, headers === null || headers === void 0 ? void 0 : headers['correlation-id'], apiClients, enablePWAKitPrivateClient, privateClientProxyEndpoint, publicClientProxyEndpoint, enableHttpOnlySessionCookies]);
218
247
 
219
248
  // Initialize the session
220
249
  (0, _react.useEffect)(() => {
@@ -240,7 +269,8 @@ const CommerceApiProvider = props => {
240
269
  passwordlessLoginCallbackURI,
241
270
  refreshTokenRegisteredCookieTTL,
242
271
  refreshTokenGuestCookieTTL,
243
- pageDesignerParams
272
+ pageDesignerParams,
273
+ enableHttpOnlySessionCookies
244
274
  }
245
275
  }, /*#__PURE__*/_react.default.createElement(CommerceApiContext.Provider, {
246
276
  value: updatedClients