From c44ddf11417783b29c36eda90f33b3dabe9af696 Mon Sep 17 00:00:00 2001 From: unandyala Date: Thu, 19 Feb 2026 16:53:21 -0600 Subject: [PATCH 01/15] Add HttpOnly session cookies for SLAS private client proxy When MRT_DISABLE_HTTPONLY_SESSION_COOKIES is 'false', token responses from SLAS are intercepted: access_token, refresh_token, and idp_access_token are set as HttpOnly cookies and stripped from the response body. The client receives access_token_expires_at for expiry checks without needing the JWT. Server-side (pwa-kit-runtime): - applyHttpOnlySessionCookies() intercepts token responses, sets HttpOnly cookies with siteId suffix, and strips tokens from body - applyProxyRequestAuthHeader() reads access token from HttpOnly cookie and sets Authorization header for SCAPI proxy requests - isScapiDomain() utility for identifying Commerce API domains - Configurable tokenResponseEndpoints and slasEndpointsRequiringAccessToken regexes for controlling which endpoints are processed Client-side (commerce-sdk-react): - useHttpOnlySessionCookies flag on Auth and CommerceApiProvider - isAccessTokenExpired() uses access_token_expires_at when HttpOnly enabled - handleTokenResponse() skips storing tokens in localStorage when HttpOnly - Provider ensures fetch credentials allow cookies to be sent Note: TAOB (Trusted Agent on Behalf) and refresh token flows with HttpOnly cookies will be handled in follow-up work. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../commerce-sdk-react/src/auth/index.test.ts | 65 ++++ packages/commerce-sdk-react/src/auth/index.ts | 69 +++- .../commerce-sdk-react/src/provider.test.tsx | 60 ++++ packages/commerce-sdk-react/src/provider.tsx | 28 +- packages/pwa-kit-dev/bin/pwa-kit-dev.js | 4 +- packages/pwa-kit-runtime/CHANGELOG.md | 1 + packages/pwa-kit-runtime/package-lock.json | 58 ++- packages/pwa-kit-runtime/package.json | 1 + .../src/ssr/server/build-remote-server.js | 242 ++++++++++++- .../ssr/server/build-remote-server.test.js | 340 ++++++++++++++++++ .../ssr-server/configure-proxy.basic.test.js | 186 ++++++++++ .../src/utils/ssr-server/configure-proxy.js | 99 ++++- .../src/utils/ssr-server/utils.js | 17 + .../src/utils/ssr-server/utils.test.js | 19 + .../app/components/_app-config/index.jsx | 7 +- packages/template-retail-react-app/app/ssr.js | 2 +- .../config/default.js | 2 +- 17 files changed, 1152 insertions(+), 48 deletions(-) diff --git a/packages/commerce-sdk-react/src/auth/index.test.ts b/packages/commerce-sdk-react/src/auth/index.test.ts index d8331ea5a2..906b7071db 100644 --- a/packages/commerce-sdk-react/src/auth/index.test.ts +++ b/packages/commerce-sdk-react/src/auth/index.test.ts @@ -1502,3 +1502,68 @@ describe('hybridAuthEnabled property toggles clearECOMSession', () => { expect(auth.get('dwsid')).toBe('test-dwsid-value') }) }) + +describe('HttpOnly Session Cookies', () => { + const expiresAtFuture = Math.floor(Date.now() / 1000) + 3600 + + const httpOnlyTokenResponse: ShopperLoginTypes.TokenResponse = { + ...TOKEN_RESPONSE, + // When HttpOnly cookies are enabled, the proxy strips tokens from the body + // and adds access_token_expires_at for client-side expiry checks + access_token_expires_at: expiresAtFuture + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('loginGuestUser stores access_token_expires_at but not tokens', async () => { + const auth = new Auth({...config, useHttpOnlySessionCookies: true}) + const loginGuestMock = helpers.loginGuestUser as jest.Mock + loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse) + + await auth.loginGuestUser() + + // access_token_expires_at should be stored for client-side expiry checks + expect(auth.get('access_token_expires_at')).toBe(String(expiresAtFuture)) + // Tokens should NOT be stored in localStorage (they're in HttpOnly cookies) + expect(auth.get('access_token')).toBeFalsy() + expect(auth.get('refresh_token_guest')).toBeFalsy() + // Common fields should still be stored + expect(auth.get('customer_id')).toBe(TOKEN_RESPONSE.customer_id) + expect(auth.get('usid')).toBe(TOKEN_RESPONSE.usid) + }) + + test('ready re-uses data when access_token_expires_at is still valid', async () => { + const auth = new Auth({...config, useHttpOnlySessionCookies: true}) + const loginGuestMock = helpers.loginGuestUser as jest.Mock + loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse) + + // First call: triggers loginGuestUser + await auth.ready() + expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) + + // Second call: access_token_expires_at is in the future, so it should re-use data + await auth.ready() + expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) // Not called again + }) + + test('ready triggers refresh when access_token_expires_at is expired', async () => { + const auth = new Auth({...config, useHttpOnlySessionCookies: true}) + + // Simulate a previous login that left behind stored data with an expired token + const expiredTime = Math.floor(Date.now() / 1000) - 100 + // @ts-expect-error private method + auth.set('access_token_expires_at', String(expiredTime)) + // @ts-expect-error private method + auth.set('refresh_token_guest', 'refresh_token') + // @ts-expect-error private method + auth.set('customer_type', 'guest') + // Set a valid JWT so parseSlasJWT works during the refresh flow + // @ts-expect-error private method + auth.set('access_token', JWTExpired) + + await auth.ready() + expect(helpers.refreshAccessToken).toHaveBeenCalled() + }) +}) diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index a9712a9373..e14c64ea85 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -54,6 +54,8 @@ interface AuthConfig extends ApiClientConfigParams { refreshTokenRegisteredCookieTTL?: number refreshTokenGuestCookieTTL?: number hybridAuthEnabled?: boolean + /** When true, token response may be sanitized (tokens in HttpOnly cookies); only set non-token fields and use access_token_expires_at for expiry. */ + useHttpOnlySessionCookies?: boolean } interface JWTHeaders { @@ -136,6 +138,7 @@ type AuthDataKeys = | 'uido' | 'idp_refresh_token' | 'dnt' + | 'access_token_expires_at' type AuthDataMap = Record< AuthDataKeys, @@ -250,6 +253,10 @@ const DATA_MAP: AuthDataMap = { uido: { storageType: 'local', key: 'uido' + }, + access_token_expires_at: { + storageType: 'local', + key: 'access_token_expires_at' } } @@ -284,6 +291,7 @@ class Auth { | undefined private hybridAuthEnabled: boolean + private useHttpOnlySessionCookies: boolean constructor(config: AuthConfig) { // Special proxy endpoint for injecting SLAS private client secret. @@ -380,6 +388,7 @@ class Auth { this.passwordlessLoginCallbackURI = config.passwordlessLoginCallbackURI || '' this.hybridAuthEnabled = config.hybridAuthEnabled || false + this.useHttpOnlySessionCookies = config.useHttpOnlySessionCookies ?? false } get(name: AuthDataKeys) { @@ -517,6 +526,23 @@ class Auth { return validTimeSeconds <= tokenAgeSeconds } + /** + * Returns whether the access token is expired. When useHttpOnlySessionCookies is true, + * uses access_token_expires_at from store; otherwise decodes the JWT from getAccessToken(). + */ + private isAccessTokenExpired(): boolean { + if (this.useHttpOnlySessionCookies) { + const expiresAt = this.get('access_token_expires_at') + if (expiresAt == null || expiresAt === '') return true + const expiresAtSec = Number(expiresAt) + if (Number.isNaN(expiresAtSec)) return true + const bufferSeconds = 60 + return Date.now() / 1000 >= expiresAtSec - bufferSeconds + } + const token = this.getAccessToken() + return !token || this.isTokenExpired(token) + } + /** * Returns the SLAS access token or an empty string if the access token * is not found in local store or if SFRA wants PWA to trigger refresh token login. @@ -680,33 +706,43 @@ class Auth { private handleTokenResponse(res: TokenResponse, isGuest: boolean) { // Delete the SFRA auth token cookie if it exists this.clearSFRAAuthToken() - this.set('access_token', res.access_token) + this.set('customer_id', res.customer_id) this.set('enc_user_id', res.enc_user_id) this.set('expires_in', `${res.expires_in}`) this.set('id_token', res.id_token) - this.set('idp_access_token', res.idp_access_token) this.set('token_type', res.token_type) this.set('customer_type', isGuest ? 'guest' : 'registered') - const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered' const refreshTokenTTLValue = this.getRefreshTokenCookieTTLValue( res.refresh_token_expires_in, isGuest ) - if (res.access_token) { - const {uido} = this.parseSlasJWT(res.access_token) - this.set('uido', uido) - } - const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue) this.set('refresh_token_expires_in', refreshTokenTTLValue.toString()) - this.set(refreshTokenKey, res.refresh_token, { - expires: expiresDate - }) + const expiresDate = this.convertSecondsToDate(refreshTokenTTLValue) + this.set('usid', res.usid ?? '', {expires: expiresDate}) - this.set('usid', res.usid, { - expires: expiresDate - }) + if (this.useHttpOnlySessionCookies) { + const uidoFromCookie = this.stores['cookie'].get('uido') + if (uidoFromCookie) this.set('uido', uidoFromCookie) + } else { + this.set('access_token', res.access_token) + this.set('idp_access_token', res.idp_access_token) + if (res.access_token) { + const {uido} = this.parseSlasJWT(res.access_token) + this.set('uido', uido) + } + const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered' + this.set(refreshTokenKey, res.refresh_token, {expires: expiresDate}) + } + if ( + res && + typeof res === 'object' && + 'access_token_expires_at' in res && + res.access_token_expires_at != null + ) { + this.set('access_token_expires_at', String(res.access_token_expires_at)) + } } async refreshAccessToken() { @@ -747,7 +783,7 @@ class Auth { // refresh flow for TAOB const accessToken = this.getAccessToken() - if (accessToken && this.isTokenExpired(accessToken)) { + if (this.isAccessTokenExpired()) { try { const {isGuest, usid, loginId, isAgent} = this.parseSlasJWT(accessToken) if (isAgent) { @@ -888,8 +924,7 @@ class Auth { return await this.pendingToken } - const accessToken = this.getAccessToken() - if (accessToken && !this.isTokenExpired(accessToken)) { + if (!this.isAccessTokenExpired()) { return this.data } diff --git a/packages/commerce-sdk-react/src/provider.test.tsx b/packages/commerce-sdk-react/src/provider.test.tsx index 7286d9d48c..eb75e491d9 100644 --- a/packages/commerce-sdk-react/src/provider.test.tsx +++ b/packages/commerce-sdk-react/src/provider.test.tsx @@ -89,4 +89,64 @@ describe('provider', () => { const authInstance = (Auth as jest.Mock).mock.instances[0] expect(authInstance.ready).toHaveBeenCalledTimes(1) }) + + test('passes useHttpOnlySessionCookies to Auth constructor', () => { + renderWithProviders(

HttpOnly cookies enabled!

, { + useHttpOnlySessionCookies: true + }) + expect(screen.getByText('HttpOnly cookies enabled!')).toBeInTheDocument() + expect(Auth).toHaveBeenCalledTimes(1) + expect(Auth).toHaveBeenCalledWith( + expect.objectContaining({ + useHttpOnlySessionCookies: true + }) + ) + }) + + test('defaults fetchOptions.credentials to same-origin when useHttpOnlySessionCookies is true', () => { + renderWithProviders(

test

, { + useHttpOnlySessionCookies: true + }) + expect(Auth).toHaveBeenCalledWith( + expect.objectContaining({ + fetchOptions: expect.objectContaining({credentials: 'same-origin'}) + }) + ) + }) + + test('overrides fetchOptions.credentials from omit to same-origin when useHttpOnlySessionCookies is true', () => { + renderWithProviders(

test

, { + useHttpOnlySessionCookies: true, + fetchOptions: {credentials: 'omit'} + }) + expect(Auth).toHaveBeenCalledWith( + expect.objectContaining({ + fetchOptions: expect.objectContaining({credentials: 'same-origin'}) + }) + ) + }) + + test('keeps fetchOptions.credentials as include when useHttpOnlySessionCookies is true', () => { + renderWithProviders(

test

, { + useHttpOnlySessionCookies: true, + fetchOptions: {credentials: 'include'} + }) + expect(Auth).toHaveBeenCalledWith( + expect.objectContaining({ + fetchOptions: expect.objectContaining({credentials: 'include'}) + }) + ) + }) + + test('does not modify fetchOptions.credentials when useHttpOnlySessionCookies is false', () => { + renderWithProviders(

test

, { + useHttpOnlySessionCookies: false, + fetchOptions: {credentials: 'omit'} + }) + expect(Auth).toHaveBeenCalledWith( + expect.objectContaining({ + fetchOptions: expect.objectContaining({credentials: 'omit'}) + }) + ) + }) }) diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx index 11605f6e8a..834e7e1952 100644 --- a/packages/commerce-sdk-react/src/provider.tsx +++ b/packages/commerce-sdk-react/src/provider.tsx @@ -48,6 +48,8 @@ export interface CommerceApiProviderProps extends ApiClientConfigParams { apiClients?: ApiClients disableAuthInit?: boolean hybridAuthEnabled?: boolean + /** When true, proxy returns tokens in HttpOnly cookies. */ + useHttpOnlySessionCookies?: boolean } /** @@ -145,12 +147,20 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { refreshTokenGuestCookieTTL, apiClients, disableAuthInit = false, - hybridAuthEnabled = false + hybridAuthEnabled = false, + useHttpOnlySessionCookies = false } = props // Set the logger based on provided configuration, or default to the console object if no logger is provided const configLogger = logger || console + // When HttpOnly cookies are enabled, ensure fetch credentials allow cookies to be sent. + // Override 'omit' or unset to 'same-origin'; keep 'same-origin' or 'include' as-is. + const effectiveFetchOptions = + useHttpOnlySessionCookies && (!fetchOptions?.credentials || fetchOptions.credentials === 'omit') + ? {...fetchOptions, credentials: 'same-origin' as RequestCredentials} + : fetchOptions + const auth = useMemo(() => { return new Auth({ clientId, @@ -160,7 +170,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { proxy, redirectURI, headers, - fetchOptions, + fetchOptions: effectiveFetchOptions, fetchedToken, enablePWAKitPrivateClient, privateClientProxyEndpoint, @@ -171,7 +181,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { passwordlessLoginCallbackURI, refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL, - hybridAuthEnabled + hybridAuthEnabled, + useHttpOnlySessionCookies }) }, [ clientId, @@ -181,7 +192,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { proxy, redirectURI, headers, - fetchOptions, + effectiveFetchOptions, fetchedToken, enablePWAKitPrivateClient, privateClientProxyEndpoint, @@ -193,7 +204,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { refreshTokenRegisteredCookieTTL, refreshTokenGuestCookieTTL, apiClients, - hybridAuthEnabled + hybridAuthEnabled, + useHttpOnlySessionCookies ]) const dwsid = auth.get(DWSID_COOKIE_NAME) @@ -212,7 +224,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { throwOnBadResponse: true, fetchOptions: { ...options.fetchOptions, - ...fetchOptions + ...effectiveFetchOptions } } } @@ -252,7 +264,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { currency }, throwOnBadResponse: true, - fetchOptions + fetchOptions: effectiveFetchOptions } return { @@ -279,7 +291,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { shortCode, siteId, proxy, - fetchOptions, + effectiveFetchOptions, locale, currency, headers?.['correlation-id'], diff --git a/packages/pwa-kit-dev/bin/pwa-kit-dev.js b/packages/pwa-kit-dev/bin/pwa-kit-dev.js index 3c77d8a620..a0faae9f27 100755 --- a/packages/pwa-kit-dev/bin/pwa-kit-dev.js +++ b/packages/pwa-kit-dev/bin/pwa-kit-dev.js @@ -257,12 +257,12 @@ const main = async () => { // This mimics how MRT sets the system environment variable const config = getConfig() || {} const disableHttpOnlySessionCookies = - config.ssrParameters?.disableHttpOnlySessionCookies || true + config.ssrParameters?.disableHttpOnlySessionCookies ?? true execSync(`${babelNode} ${inspect ? '--inspect' : ''} ${babelArgs} ${entrypoint}`, { env: { ...process.env, ...(noHMR ? {HMR: 'false'} : {}), - MRT_DISABLE_HTTPONLY_SESSION_COOKIES: disableHttpOnlySessionCookies + MRT_DISABLE_HTTPONLY_SESSION_COOKIES: String(disableHttpOnlySessionCookies) } }) }) diff --git a/packages/pwa-kit-runtime/CHANGELOG.md b/packages/pwa-kit-runtime/CHANGELOG.md index f98c8b4482..ae90433187 100644 --- a/packages/pwa-kit-runtime/CHANGELOG.md +++ b/packages/pwa-kit-runtime/CHANGELOG.md @@ -1,5 +1,6 @@ ## v3.17.0-dev - Add Node 24 support. Migrate deprecated Node.js `url.parse()` and `url.format()` to the WHATWG `URL` [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) +- Add HttpOnly session cookies for SLAS private client proxy: when `MRT_DISABLE_HTTPONLY_SESSION_COOKIES` is not `'true'`, token responses are intercepted and session tokens are set as HttpOnly cookies; token fields (access_token, idp_access_token, refresh_token) are stripped from the response body. The client continues to get expires_in and refresh_token_expires_in from the response body. This runs before `onSLASPrivateProxyRes`; custom callbacks receive the sanitized response and should read from response headers (e.g. Set-Cookie) rather than the body when using HttpOnly session cookies. An error is thrown if `siteId` is missing in commerce API parameters. ## v3.16.0 (Feb 12, 2026) - Migrate AWS SDK from v2 to v3 [#3566](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3566) diff --git a/packages/pwa-kit-runtime/package-lock.json b/packages/pwa-kit-runtime/package-lock.json index 0d8ea1fc57..40140b945b 100644 --- a/packages/pwa-kit-runtime/package-lock.json +++ b/packages/pwa-kit-runtime/package-lock.json @@ -19,6 +19,7 @@ "express": "^4.19.2", "header-case": "1.0.1", "http-proxy-middleware": "^2.0.6", + "jwt-decode": "^4.0.0", "merge-descriptors": "^1.0.1", "morgan": "^1.10.0", "semver": "^7.5.2", @@ -1482,6 +1483,7 @@ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1522,6 +1524,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -1531,6 +1534,7 @@ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", @@ -1547,6 +1551,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", @@ -1563,6 +1568,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -1572,6 +1578,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1581,6 +1588,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" @@ -1594,6 +1602,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", @@ -1620,6 +1629,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1638,6 +1648,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1647,6 +1658,7 @@ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" @@ -1660,6 +1672,7 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.28.5" }, @@ -1697,6 +1710,7 @@ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", @@ -1711,6 +1725,7 @@ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1729,6 +1744,7 @@ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" @@ -1832,6 +1848,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" @@ -1842,6 +1859,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -1852,6 +1870,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -1860,13 +1879,15 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -2594,6 +2615,7 @@ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -2706,6 +2728,7 @@ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz", "integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==", "license": "Apache-2.0", + "peer": true, "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -2746,7 +2769,6 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", @@ -2909,7 +2931,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" + "license": "CC-BY-4.0", + "peer": true }, "node_modules/chokidar": { "version": "3.6.0", @@ -2991,7 +3014,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cookie": { "version": "0.7.2", @@ -3161,7 +3185,8 @@ "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/encodeurl": { "version": "2.0.0", @@ -3232,6 +3257,7 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -3262,7 +3288,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -3512,6 +3537,7 @@ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -3660,7 +3686,6 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", - "peer": true, "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -3861,6 +3886,7 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", + "peer": true, "bin": { "jsesc": "bin/jsesc" }, @@ -3886,6 +3912,7 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", + "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -3913,6 +3940,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -3945,6 +3981,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", + "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -4196,7 +4233,8 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/nodemon": { "version": "2.0.22", @@ -5085,6 +5123,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -5164,7 +5203,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" + "license": "ISC", + "peer": true } } } diff --git a/packages/pwa-kit-runtime/package.json b/packages/pwa-kit-runtime/package.json index 8f61f4f0aa..75cd49f3bb 100644 --- a/packages/pwa-kit-runtime/package.json +++ b/packages/pwa-kit-runtime/package.json @@ -34,6 +34,7 @@ "@aws-sdk/client-dynamodb": "^3.989.0", "@aws-sdk/lib-dynamodb": "^3.989.0", "@h4ad/serverless-adapter": "4.4.0", + "jwt-decode": "^4.0.0", "@loadable/babel-plugin": "^5.15.3", "cosmiconfig": "8.1.3", "cross-env": "^5.2.1", diff --git a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js index 04fb15604d..9eda34da4c 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js +++ b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js @@ -32,7 +32,12 @@ import dns from 'dns' import express from 'express' import {PersistentCache} from '../../utils/ssr-cache' import merge from 'merge-descriptors' -import {Headers, X_HEADERS_TO_REMOVE_ORIGIN, X_MOBIFY_REQUEST_CLASS} from '../../utils/ssr-proxying' +import { + Headers, + X_HEADERS_TO_REMOVE_ORIGIN, + X_MOBIFY_REQUEST_CLASS, + cookieAsString +} from '../../utils/ssr-proxying' import assert from 'assert' import semver from 'semver' import pkg from '../../../package.json' @@ -60,6 +65,8 @@ import {CallbackResolver} from '@h4ad/serverless-adapter/lib/resolvers/callback' import {ApiGatewayV1Adapter} from '@h4ad/serverless-adapter/lib/adapters/aws' import {ExpressFramework} from '@h4ad/serverless-adapter/lib/frameworks/express' import {is as typeis} from 'type-is' +import {jwtDecode} from 'jwt-decode' +import {getConfig} from '../../utils/ssr-config' /** * An Array of mime-types (Content-Type values) that are considered @@ -89,6 +96,151 @@ export const isBinary = (headers) => { return isContentTypeBinary(headers) } +// Refresh token cookie TTL defaults (seconds). Must stay in sync with commerce-sdk-react auth constants. +const DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL = 30 * 24 * 60 * 60 +const DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = 90 * 24 * 60 * 60 + +/** + * Computes refresh token cookie TTL in seconds. Same logic as Auth.getRefreshTokenCookieTTLValue in commerce-sdk-react: + * 1. Override value (if valid), 2. SLAS response value, 3. Default (guest or registered). + * Used when setting HttpOnly refresh token cookies. Keep in sync with commerce-sdk-react auth. + * @private + */ +function getRefreshTokenCookieTTL(refreshTokenExpiresInSLASValue, isGuest, options = {}) { + const overrideValue = isGuest + ? options.refreshTokenGuestCookieTTL + : options.refreshTokenRegisteredCookieTTL + const defaultValue = isGuest + ? DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL + : DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL + const isOverrideValid = + typeof overrideValue === 'number' && overrideValue > 0 && overrideValue <= defaultValue + if (!isOverrideValid && overrideValue !== undefined) { + logger.warn('You are attempting to use an invalid refresh token TTL value.') + } + return isOverrideValid ? overrideValue : refreshTokenExpiresInSLASValue || defaultValue +} + +/** + * When HttpOnly session cookies are enabled: set tokens as HttpOnly cookies, TTLs as non-HttpOnly, + * strip token fields from body, and remove upstream Set-Cookie so we control all cookies. + * @private + */ +function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options) { + const siteId = options.mobify?.app?.commerceAPI?.parameters?.siteId + if (!siteId || typeof siteId !== 'string' || siteId.trim() === '') { + throw new Error( + 'HttpOnly session cookies are enabled but siteId is missing. ' + + 'Set mobify.app.commerceAPI.parameters.siteId in your app config.' + ) + } + + let parsed + try { + parsed = JSON.parse(responseBuffer.toString('utf8')) + } catch { + return responseBuffer + } + + const cookieOptsBase = { + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: true + } + const appendCookie = (cookieObj) => { + res.append(SET_COOKIE, cookieAsString(cookieObj)) + } + + // Add our cookies (append Set-Cookie); same names override, other upstream cookies are preserved + const siteIdTrimmed = siteId.trim() + + // Access token + const accessToken = parsed.access_token + const expiresInSeconds = typeof parsed.expires_in === 'number' ? parsed.expires_in : 1800 + if (accessToken) { + const accessExpires = new Date(Date.now() + expiresInSeconds * 1000) + appendCookie({ + name: `access_token_${siteIdTrimmed}`, + value: accessToken, + ...cookieOptsBase, + expires: accessExpires + }) + } + + // Decode access token once for access_token_expires_at and guest/registered (same as commerce-sdk-react parseSlasJWT) + let accessTokenPayload = null + if (accessToken) { + try { + accessTokenPayload = jwtDecode(accessToken) + } catch (error) { + throw new Error(`Failed to decode access token JWT: ${error.message || error}. `) + } + } + // access_token_expires_at: Unix timestamp (seconds) when the access token expires; use JWT iat when available; added to response body so client can store in localStorage (no cookie expiry to manage) + let accessTokenExpiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds + if (accessTokenPayload && typeof accessTokenPayload.iat === 'number') { + accessTokenExpiresAt = accessTokenPayload.iat + expiresInSeconds + } + + // IDP access token + const idpAccessToken = parsed.idp_access_token + if (idpAccessToken) { + const expiresInSeconds = typeof parsed.expires_in === 'number' ? parsed.expires_in : 1800 + const idpExpires = new Date(Date.now() + expiresInSeconds * 1000) + appendCookie({ + name: `idp_access_token_${siteIdTrimmed}`, + value: idpAccessToken, + ...cookieOptsBase, + expires: idpExpires + }) + } + + // Refresh token: set only the cookie for this user type (cc-nx-g = guest, cc-nx = registered); isGuest from JWT isb same as commerce-sdk-react parseSlasJWT + const refreshToken = parsed.refresh_token + let isGuest = true + let uido = null + if (accessTokenPayload && typeof accessTokenPayload.isb === 'string') { + const isbParts = accessTokenPayload.isb.split('::') + isGuest = isbParts[1] === 'upn:Guest' + const uidoPart = isbParts[0].split('uido:')[1] + if (uidoPart) uido = uidoPart + } + const refreshTTL = getRefreshTokenCookieTTL(parsed.refresh_token_expires_in, isGuest) + const refreshExpires = new Date(Date.now() + refreshTTL * 1000) + if (refreshToken) { + const refreshCookieName = isGuest ? `cc-nx-g_${siteIdTrimmed}` : `cc-nx_${siteIdTrimmed}` + appendCookie({ + name: refreshCookieName, + value: refreshToken, + ...cookieOptsBase, + expires: refreshExpires + }) + } + + // uido: IDP origin from JWT (e.g. "slas", "ecom"); non-HttpOnly so client can read it for useCustomerType/isExternal + if (uido) { + const accessExpires = new Date(Date.now() + expiresInSeconds * 1000) + appendCookie({ + name: `uido_${siteIdTrimmed}`, + value: uido, + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: false, + expires: accessExpires + }) + } + + // Strip from body only the fields set as HttpOnly cookies; add access_token_expires_at so client can store for expiry checks (uido is in non-HttpOnly cookie for client to read) + const stripped = {...parsed} + delete stripped.access_token + delete stripped.idp_access_token + delete stripped.refresh_token + stripped.access_token_expires_at = accessTokenExpiresAt + return Buffer.from(JSON.stringify(stripped), 'utf8') +} + /** * Environment variables that must be set for the Express app to run remotely. * @@ -160,6 +312,17 @@ export const RemoteServerFactory = { applySLASPrivateClientToEndpoints: /\/oauth2\/(token|passwordless\/(login|token)|password\/(reset|action))/, + // A regex for identifying which SLAS endpoints return tokens (access_token, refresh_token) + // in the response body. Used to determine which responses should have HttpOnly session + // cookies applied when that feature is enabled. Users can override this in ssr.js. + tokenResponseEndpoints: /\/oauth2\/(token|passwordless\/token)$/, + + // A regex for identifying which SLAS auth endpoints (/shopper/auth/) require the + // shopper's access token in the Authorization header (Bearer token from HttpOnly cookie). + // Most SLAS auth endpoints use Basic Auth with client credentials, but some like logout + // require the shopper's Bearer token. Users can override this in ssr.js. + slasEndpointsRequiringAccessToken: /\/oauth2\/logout/, + // Custom callback to modify the SLAS private client proxy request. This callback is invoked // after the built-in proxy request handling. Users can provide additional // request modifications (e.g., custom headers). @@ -167,8 +330,11 @@ export const RemoteServerFactory = { onSLASPrivateProxyReq: undefined, // Custom callback to modify the SLAS private client proxy response. This callback is invoked - // after the built-in proxy response handling. Users can modify or replace - // the response buffer. + // after the built-in proxy response handling (including HttpOnly session cookie handling when enabled). + // When HttpOnly session cookies are enabled (MRT_DISABLE_HTTPONLY_SESSION_COOKIES=false), the callback + // receives the response with tokens already moved to HttpOnly cookies and stripped from the body. + // Custom callbacks must not rely on token fields in the response body in that case; read from + // response headers (e.g. Set-Cookie) if needed. // Signature: (responseBuffer, proxyRes, req, res) => Buffer onSLASPrivateProxyRes: undefined } @@ -211,6 +377,19 @@ export const RemoteServerFactory = { `${options.slasApiPath.source}(${options.applySLASPrivateClientToEndpoints.source})` ) + // Note: HttpOnly session cookies are controlled by the MRT_DISABLE_HTTPONLY_SESSION_COOKIES + // env var (set by MRT in production, pwa-kit-dev locally). Read directly where needed. + + // Extract siteId from app configuration for SCAPI auth + // This will be used to read the correct access token cookie + try { + const config = getConfig({buildDirectory: options.buildDir}) + options.siteId = config?.app?.commerceAPI?.parameters?.siteId || null + } catch (e) { + // Config may not be available yet (e.g., during build), that's okay + options.siteId = null + } + return options }, @@ -367,7 +546,14 @@ export const RemoteServerFactory = { * @private */ _configureProxyConfigs(options) { - configureProxyConfigs(options.appHostname, options.protocol) + const siteId = options.siteId || null + const slasEndpointsRequiringAccessToken = options.slasEndpointsRequiringAccessToken + configureProxyConfigs( + options.appHostname, + options.protocol, + siteId, + slasEndpointsRequiringAccessToken + ) }, /** @@ -963,6 +1149,47 @@ export const RemoteServerFactory = { onProxyRes: responseInterceptor((responseBuffer, proxyRes, req, res) => { let workingBuffer = responseBuffer try { + // When HttpOnly session cookies are enabled and response is 200 from a token endpoint, + // set tokens as HttpOnly cookies and strip from body. + // Check against tokenResponseEndpoints regex (configurable in ssr.js) + const isTokenEndpoint = req.path?.match(options.tokenResponseEndpoints) + const httpOnlySessionCookiesEnabled = + process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES === 'false' + if ( + httpOnlySessionCookiesEnabled && + proxyRes.statusCode === 200 && + isTokenEndpoint + ) { + try { + workingBuffer = applyHttpOnlySessionCookies( + workingBuffer, + proxyRes, + req, + res, + options + ) + } catch (error) { + // HttpOnly configuration errors should fail the request (do not leak tokens) + res.statusCode = 500 + res.statusMessage = 'Internal Server Error' + logger.error('Error applying HttpOnly session cookies', { + namespace: '_setupSlasPrivateClientProxy', + additionalProperties: { + error: error.message || error + } + }) + return Buffer.from( + JSON.stringify({ + error: 'Internal server error', + message: + error.message || + 'An error occurred processing the authentication response' + }), + 'utf8' + ) + } + } + // If the passwordless login endpoint returns a 404, which corresponds to a user // email not being found, we mask it with a 200 OK response so that it is not // obvious that the user does not exist. @@ -1387,6 +1614,13 @@ export const RemoteServerFactory = { * proxy handler. Requires PWA_KIT_SLAS_CLIENT_SECRET environment variable. * @param {RegExp} [options.applySLASPrivateClientToEndpoints] - A regex pattern to match * SLAS endpoints where the Authorization header should be injected. + * @param {RegExp} [options.tokenResponseEndpoints] - A regex pattern to match SLAS endpoints + * that return tokens in the response body. Used to determine which responses should have HttpOnly + * session cookies applied. Defaults to /\/oauth2\/(token|passwordless\/token)$/. + * @param {RegExp} [options.slasEndpointsRequiringAccessToken] - A regex pattern to match SLAS auth + * endpoints (/shopper/auth/) that require the shopper's access token in the Authorization header (Bearer token). + * Most SLAS auth endpoints use Basic Auth with client credentials, but some like logout require the shopper's + * Bearer token. Defaults to /\/oauth2\/logout/. * @param {function} [options.onSLASPrivateProxyReq] - Custom callback to modify SLAS private client * proxy requests. Called after built-in request handling. Signature: (proxyRequest, incomingRequest, res) => void. * Use this to add custom headers or modify the proxy request. diff --git a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js index c4b64bf80a..6a5f482f9d 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js +++ b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js @@ -29,6 +29,13 @@ jest.mock('../../utils/logger-instance', () => ({ } })) +// Build a minimal JWT (unsigned) so jwt-decode can read payload; avoids mocking jwt-decode +function makeJWT(payload) { + const header = Buffer.from(JSON.stringify({alg: 'HS256', typ: 'JWT'})).toString('base64url') + const payloadPart = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${header}.${payloadPart}.sig` +} + describe('the once function', () => { test('should prevent a function being called more than once', () => { const fn = jest.fn(() => ({test: 'test'})) @@ -185,6 +192,7 @@ describe('SLAS private proxy', () => { afterEach(() => { // Clean up environment variables delete process.env.PWA_KIT_SLAS_CLIENT_SECRET + delete process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES }) test('returns 404 when useSLASPrivateClient is false', async () => { @@ -357,6 +365,338 @@ describe('SLAS private proxy', () => { mockSlasServerInstance.close() } }) + +}) + +describe('HttpOnly session cookies', () => { + let request + let mockExpress + + beforeEach(() => { + mockExpress = require('express') + request = require('supertest') + }) + + afterEach(() => { + delete process.env.PWA_KIT_SLAS_CLIENT_SECRET + delete process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES + }) + + test('does not process when MRT_DISABLE_HTTPONLY_SESSION_COOKIES is not set', async () => { + delete process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES + + const mockSlasServer = mockExpress() + mockSlasServer.post('/shopper/auth/v1/oauth2/token', (req, res) => { + res.status(200).json({ + access_token: 'mock-token', + expires_in: 1800, + refresh_token: 'mock-refresh-token' + }) + }) + + const mockSlasServerInstance = mockSlasServer.listen(0) + const mockSlasPort = mockSlasServerInstance.address().port + + try { + const app = mockExpress() + const options = RemoteServerFactory._configure({ + useSLASPrivateClient: true, + slasTarget: `http://localhost:${mockSlasPort}`, + mobify: { + app: { + commerceAPI: { + parameters: { + shortCode: 'test', + organizationId: 'f_ecom_test', + clientId: 'test-client-id', + siteId: 'testsite' + } + } + } + } + }) + + process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret' + + RemoteServerFactory._setupSlasPrivateClientProxy(app, options) + + const response = await request(app).post( + '/mobify/slas/private/shopper/auth/v1/oauth2/token' + ) + + // Should return original response with tokens (no HttpOnly processing) + expect(response.status).toBe(200) + expect(response.body.access_token).toBe('mock-token') + expect(response.body.refresh_token).toBe('mock-refresh-token') + expect(response.headers['set-cookie']).toBeUndefined() + } finally { + mockSlasServerInstance.close() + } + }) + + test('returns 500 when siteId is missing', async () => { + process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false' + + const mockSlasServer = mockExpress() + mockSlasServer.post('/shopper/auth/v1/oauth2/token', (req, res) => { + res.status(200).json({ + access_token: 'mock-token', + expires_in: 1800, + refresh_token: 'mock-refresh-token' + }) + }) + + const mockSlasServerInstance = mockSlasServer.listen(0) + const mockSlasPort = mockSlasServerInstance.address().port + + try { + const app = mockExpress() + const options = RemoteServerFactory._configure({ + useSLASPrivateClient: true, + slasTarget: `http://localhost:${mockSlasPort}`, + mobify: { + app: { + commerceAPI: { + parameters: { + shortCode: 'test', + organizationId: 'f_ecom_test', + clientId: 'test-client-id' + // siteId is intentionally missing + } + } + } + } + }) + + process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret' + + RemoteServerFactory._setupSlasPrivateClientProxy(app, options) + + const response = await request(app).post( + '/mobify/slas/private/shopper/auth/v1/oauth2/token' + ) + + expect(response.status).toBe(500) + expect(response.body.error).toBe('Internal server error') + expect(response.body.message).toContain('siteId is missing') + } finally { + mockSlasServerInstance.close() + } + }) + + test('skips non-token endpoints like logout', async () => { + process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false' + + const mockSlasServer = mockExpress() + mockSlasServer.post('/shopper/auth/v1/oauth2/logout', (req, res) => { + res.status(200).json({success: true}) + }) + + const mockSlasServerInstance = mockSlasServer.listen(0) + const mockSlasPort = mockSlasServerInstance.address().port + + try { + const app = mockExpress() + const options = RemoteServerFactory._configure({ + useSLASPrivateClient: true, + slasTarget: `http://localhost:${mockSlasPort}`, + mobify: { + app: { + commerceAPI: { + parameters: { + shortCode: 'test', + organizationId: 'f_ecom_test', + clientId: 'test-client-id', + siteId: 'testsite' + } + } + } + } + }) + + process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret' + + RemoteServerFactory._setupSlasPrivateClientProxy(app, options) + + const response = await request(app).post( + '/mobify/slas/private/shopper/auth/v1/oauth2/logout' + ) + + expect(response.status).toBe(200) + expect(response.body.success).toBe(true) + expect(response.headers['set-cookie']).toBeUndefined() + } finally { + mockSlasServerInstance.close() + } + }) + + test('sets HttpOnly cookies and strips tokens from response body', async () => { + process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false' + + const mockSlasServer = mockExpress() + mockSlasServer.post('/shopper/auth/v1/oauth2/token', (req, res) => { + const accessToken = makeJWT({ + iat: 1000, + isb: 'uido:ecom::upn:Guest::uidn:Guest::gcid:g1::rcid:r1::chid:testsite' + }) + res.status(200).json({ + access_token: accessToken, + expires_in: 1800, + refresh_token: 'mock-refresh-token' + }) + }) + + const mockSlasServerInstance = mockSlasServer.listen(0) + const mockSlasPort = mockSlasServerInstance.address().port + + try { + const app = mockExpress() + const options = RemoteServerFactory._configure({ + useSLASPrivateClient: true, + slasTarget: `http://localhost:${mockSlasPort}`, + mobify: { + app: { + commerceAPI: { + parameters: { + shortCode: 'test', + organizationId: 'f_ecom_test', + clientId: 'test-client-id', + siteId: 'testsite' + } + } + } + } + }) + + process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret' + + RemoteServerFactory._setupSlasPrivateClientProxy(app, options) + + const response = await request(app).post( + '/mobify/slas/private/shopper/auth/v1/oauth2/token' + ) + + expect(response.status).toBe(200) + expect(response.body).not.toHaveProperty('access_token') + expect(response.body).not.toHaveProperty('refresh_token') + expect(response.body).toHaveProperty('access_token_expires_at') + + expect(response.headers['set-cookie']).toBeDefined() + const cookies = response.headers['set-cookie'] + expect(cookies.some(c => c.includes('access_token_testsite'))).toBe(true) + expect(cookies.some(c => c.includes('cc-nx-g_testsite'))).toBe(true) + } finally { + mockSlasServerInstance.close() + } + }) + + test('returns 500 when JWT decode fails', async () => { + process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false' + + const mockSlasServer = mockExpress() + mockSlasServer.post('/shopper/auth/v1/oauth2/token', (req, res) => { + res.status(200).json({ + access_token: 'invalid-jwt-not-base64', + expires_in: 1800, + refresh_token: 'mock-refresh-token' + }) + }) + + const mockSlasServerInstance = mockSlasServer.listen(0) + const mockSlasPort = mockSlasServerInstance.address().port + + try { + const app = mockExpress() + const options = RemoteServerFactory._configure({ + useSLASPrivateClient: true, + slasTarget: `http://localhost:${mockSlasPort}`, + mobify: { + app: { + commerceAPI: { + parameters: { + shortCode: 'test', + organizationId: 'f_ecom_test', + clientId: 'test-client-id', + siteId: 'testsite' + } + } + } + } + }) + + process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret' + + RemoteServerFactory._setupSlasPrivateClientProxy(app, options) + + const response = await request(app).post( + '/mobify/slas/private/shopper/auth/v1/oauth2/token' + ) + + expect(response.status).toBe(500) + expect(response.body.error).toBe('Internal server error') + expect(response.body.message).toContain('Failed to decode access token JWT') + } finally { + mockSlasServerInstance.close() + } + }) + + test('processes passwordless token endpoint', async () => { + process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false' + + const mockSlasServer = mockExpress() + mockSlasServer.post('/shopper/auth/v1/oauth2/passwordless/token', (req, res) => { + const accessToken = makeJWT({ + iat: 1000, + isb: 'uido:ecom::upn:user@example.com::uidn:User::gcid:g1::rcid:r1::chid:testsite' + }) + res.status(200).json({ + access_token: accessToken, + expires_in: 1800, + refresh_token: 'mock-refresh-token' + }) + }) + + const mockSlasServerInstance = mockSlasServer.listen(0) + const mockSlasPort = mockSlasServerInstance.address().port + + try { + const app = mockExpress() + const options = RemoteServerFactory._configure({ + useSLASPrivateClient: true, + slasTarget: `http://localhost:${mockSlasPort}`, + mobify: { + app: { + commerceAPI: { + parameters: { + shortCode: 'test', + organizationId: 'f_ecom_test', + clientId: 'test-client-id', + siteId: 'testsite' + } + } + } + } + }) + + process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret' + + RemoteServerFactory._setupSlasPrivateClientProxy(app, options) + + const response = await request(app).post( + '/mobify/slas/private/shopper/auth/v1/oauth2/passwordless/token' + ) + + expect(response.status).toBe(200) + expect(response.body).not.toHaveProperty('access_token') + expect(response.body).not.toHaveProperty('refresh_token') + + expect(response.headers['set-cookie']).toBeDefined() + const cookies = response.headers['set-cookie'] + expect(cookies.some(c => c.includes('access_token_testsite'))).toBe(true) + } finally { + mockSlasServerInstance.close() + } + }) }) describe('errorHandlerMiddleware logic', () => { diff --git a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js index 4ea7f8f51b..eb23ea3baa 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js @@ -6,10 +6,19 @@ */ import { applyProxyRequestHeaders, + applyProxyRequestAuthHeader, ALLOWED_CACHING_PROXY_REQUEST_METHODS, configureProxy } from './configure-proxy' import * as ssrProxying from '../ssr-proxying' +import * as utils from './utils' +import cookie from 'cookie' + +jest.mock('cookie') +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + isScapiDomain: jest.fn() +})) describe('applyProxyRequestHeaders', () => { it('removes a header not present in new headers', () => { @@ -85,3 +94,180 @@ describe('configureProxy ALLOWED_CACHING_PROXY_REQUEST_METHODS', () => { expect(typeof result).toBe('function') }) }) + +describe('applyProxyRequestAuthHeader', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('applies Bearer token for non-SLAS Shopper API endpoints', () => { + utils.isScapiDomain.mockReturnValue(true) + cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + + const proxyRequest = { + setHeader: jest.fn(), + removeHeader: jest.fn() + } + const incomingRequest = { + url: '/shopper/products/v1/products', + headers: {cookie: 'access_token_RefArch=test-access-token'} + } + + applyProxyRequestAuthHeader({ + proxyRequest, + incomingRequest, + caching: false, + siteId: 'RefArch', + targetHost: 'abc-001.api.commercecloud.salesforce.com', + slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + }) + + expect(proxyRequest.setHeader).toHaveBeenCalledWith('authorization', 'Bearer test-access-token') + }) + + it('applies Bearer token for SLAS logout endpoint', () => { + utils.isScapiDomain.mockReturnValue(true) + cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + + const proxyRequest = { + setHeader: jest.fn() + } + const incomingRequest = { + url: '/shopper/auth/v1/oauth2/logout', + headers: {cookie: 'access_token_RefArch=test-access-token'} + } + + applyProxyRequestAuthHeader({ + proxyRequest, + incomingRequest, + caching: false, + siteId: 'RefArch', + targetHost: 'abc-001.api.commercecloud.salesforce.com', + slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + }) + + expect(proxyRequest.setHeader).toHaveBeenCalledWith('authorization', 'Bearer test-access-token') + }) + + it('does not apply Bearer token for SLAS token endpoint', () => { + utils.isScapiDomain.mockReturnValue(true) + cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + + const proxyRequest = { + setHeader: jest.fn() + } + const incomingRequest = { + url: '/shopper/auth/v1/oauth2/token', + headers: {cookie: 'access_token_RefArch=test-access-token'} + } + + applyProxyRequestAuthHeader({ + proxyRequest, + incomingRequest, + caching: false, + siteId: 'RefArch', + targetHost: 'abc-001.api.commercecloud.salesforce.com', + slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + }) + + // Should not set authorization header for token endpoint (uses Basic Auth) + expect(proxyRequest.setHeader).not.toHaveBeenCalled() + }) + + it('does not apply Bearer token when caching is true', () => { + utils.isScapiDomain.mockReturnValue(true) + cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + + const proxyRequest = { + setHeader: jest.fn() + } + const incomingRequest = { + url: '/shopper/products/v1/products', + headers: {cookie: 'access_token_RefArch=test-access-token'} + } + + applyProxyRequestAuthHeader({ + proxyRequest, + incomingRequest, + caching: true, + siteId: 'RefArch', + targetHost: 'abc-001.api.commercecloud.salesforce.com', + slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + }) + + // Caching proxies don't use auth + expect(proxyRequest.setHeader).not.toHaveBeenCalled() + }) + + it('does not apply Bearer token when siteId is not provided', () => { + utils.isScapiDomain.mockReturnValue(true) + cookie.parse.mockReturnValue({}) + + const proxyRequest = { + setHeader: jest.fn() + } + const incomingRequest = { + url: '/shopper/products/v1/products', + headers: {} + } + + applyProxyRequestAuthHeader({ + proxyRequest, + incomingRequest, + caching: false, + siteId: null, + targetHost: 'abc-001.api.commercecloud.salesforce.com', + slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + }) + + expect(proxyRequest.setHeader).not.toHaveBeenCalled() + }) + + it('does not apply Bearer token when target is not SCAPI domain', () => { + utils.isScapiDomain.mockReturnValue(false) + cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + + const proxyRequest = { + setHeader: jest.fn() + } + const incomingRequest = { + url: '/api/products', + headers: {cookie: 'access_token_RefArch=test-access-token'} + } + + applyProxyRequestAuthHeader({ + proxyRequest, + incomingRequest, + caching: false, + siteId: 'RefArch', + targetHost: 'external-api.example.com', + slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + }) + + expect(proxyRequest.setHeader).not.toHaveBeenCalled() + }) + + it('does not apply Bearer token when cookie is not present', () => { + utils.isScapiDomain.mockReturnValue(true) + cookie.parse.mockReturnValue({}) // No access token cookie + + const proxyRequest = { + setHeader: jest.fn() + } + const incomingRequest = { + url: '/shopper/products/v1/products', + headers: {} + } + + applyProxyRequestAuthHeader({ + proxyRequest, + incomingRequest, + caching: false, + siteId: 'RefArch', + targetHost: 'abc-001.api.commercecloud.salesforce.com', + slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + }) + + expect(proxyRequest.setHeader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js index 7fe0c75f24..ae0e9f1bc6 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js @@ -5,10 +5,11 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import {createProxyMiddleware} from 'http-proxy-middleware' +import cookie from 'cookie' import {rewriteProxyRequestHeaders, rewriteProxyResponseHeaders} from '../ssr-proxying' import {proxyConfigs} from '../ssr-shared' import {processExpressResponse} from './process-express-response' -import {isRemote, localDevLog, verboseProxyLogging} from './utils' +import {isRemote, localDevLog, verboseProxyLogging, isScapiDomain} from './utils' import logger from '../logger-instance' import {getEnvBasePath} from '../ssr-namespace-paths' @@ -24,6 +25,69 @@ export const ALLOWED_CACHING_PROXY_REQUEST_METHODS = ['HEAD', 'GET', 'OPTIONS'] */ const generalProxyPathRE = /^\/mobify\/proxy\/([^/]+)(\/.*)$/ +/** + * Apply the Authorization header with the shopper's access token (Bearer token) to a proxy request. + * + * This function is intended to be called from within a proxy's onProxyReq method. + * It reads the access token from HttpOnly cookies and sets it as the Authorization header + * for applicable SCAPI endpoints. + * + * Logic for determining if Bearer token should be applied: + * 1. Caching proxies never use auth (skip) + * 2. siteId must be provided (skip if not) + * 3. Target must be SCAPI domain (skip if not) + * 4. For SLAS auth endpoints (/shopper/auth/*): Only apply if they match the regex + * - Most use Basic Auth (client credentials), but some like /oauth2/logout need Bearer token + * 5. For non-SLAS auth endpoints (e.g., /shopper/products, /shopper/baskets): Always apply Bearer token + * + * @private + * @function + * @param proxyRequest {http.ClientRequest} the request that will be sent to the target host + * @param incomingRequest {http.IncomingMessage} the request made to this Express app + * @param caching {Boolean} true for a caching proxy, false for a standard proxy + * @param siteId {String} the site ID for the current request + * @param targetHost {String} the target hostname (host+port) + * @param slasEndpointsRequiringAccessToken {RegExp} regex for SLAS auth endpoints that need Bearer token + */ +export const applyProxyRequestAuthHeader = ({ + proxyRequest, + incomingRequest, + caching, + siteId, + targetHost, + slasEndpointsRequiringAccessToken +}) => { + const url = incomingRequest.url + + // Skip if: caching proxy, no siteId, not SCAPI domain, or no URL + if (caching || !siteId || !isScapiDomain(targetHost) || !url) { + return + } + if (url.startsWith('/shopper/auth/')) { + // For SLAS auth endpoints, only apply if they match the configured regex + // Most SLAS endpoints use Basic Auth, only specific ones like /oauth2/logout need Bearer token + if (!slasEndpointsRequiringAccessToken || !url.match(slasEndpointsRequiringAccessToken)) { + return + } + } + // If we reach here, either: + // 1. It's a SLAS auth endpoint that matched the regex, OR + // 2. It's a non-SLAS auth endpoint (which always requires Bearer token) + + // Get access token from HttpOnly cookie + const cookieHeader = incomingRequest.headers.cookie + if (!cookieHeader) return + + const cookies = cookie.parse(cookieHeader) + const tokenKey = `access_token_${siteId.trim()}` + const accessToken = cookies[tokenKey] + + if (accessToken) { + // Always override - cookie-based auth takes precedence + proxyRequest.setHeader('authorization', `Bearer ${accessToken}`) + } +} + /** * Apply proxy headers to a request that is being proxied. * @@ -119,6 +183,8 @@ export const applyProxyRequestHeaders = ({ * the origin ('http' or 'https', defaults to 'https') * @param caching {Boolean} true for a caching proxy, false for a * standard proxy. + * @param siteId {String} the site ID for the current request + * @param slasEndpointsRequiringAccessToken {RegExp} regex for SLAS auth endpoints that require Bearer token * @returns {middleware} function to pass to expressApp.use() */ export const configureProxy = ({ @@ -127,7 +193,9 @@ export const configureProxy = ({ targetProtocol, targetHost, appProtocol = /* istanbul ignore next */ 'https', - caching + caching, + siteId = null, + slasEndpointsRequiringAccessToken = /\/oauth2\/logout/ }) => { // This configuration must match the behaviour of the proxying // in CloudFront. @@ -194,6 +262,7 @@ export const configureProxy = ({ * this Express app that prompted the proxying */ onProxyReq: (proxyRequest, incomingRequest) => { + // First, apply standard proxy headers (Host, Origin, etc.) applyProxyRequestHeaders({ proxyRequest, incomingRequest, @@ -202,6 +271,16 @@ export const configureProxy = ({ targetHost, targetProtocol }) + + // Apply Authorization header with shopper's access token from HttpOnly cookie + applyProxyRequestAuthHeader({ + proxyRequest, + incomingRequest, + caching, + siteId, + targetHost, + slasEndpointsRequiringAccessToken + }) }, onProxyRes: (proxyResponse, req) => { @@ -293,9 +372,16 @@ export const configureProxy = ({ * to which requests are sent to the Express app) * @param {String} appProtocol {String} the protocol to use to make requests to * the origin ('http' or 'https', defaults to 'https') + * @param {String} siteId - the site ID for the current request + * @param {RegExp} slasEndpointsRequiringAccessToken - regex for SLAS auth endpoints that require Bearer token * @private */ -export const configureProxyConfigs = (appHostname, appProtocol) => { +export const configureProxyConfigs = ( + appHostname, + appProtocol, + siteId = null, + slasEndpointsRequiringAccessToken = /\/oauth2\/logout/ +) => { localDevLog('') proxyConfigs.forEach((config) => { localDevLog( @@ -307,7 +393,9 @@ export const configureProxyConfigs = (appHostname, appProtocol) => { targetHost: config.host, appProtocol, appHostname, - caching: false + caching: false, + siteId, + slasEndpointsRequiringAccessToken }) config.cachingProxy = configureProxy({ proxyPath: config.cachingPath, @@ -315,7 +403,8 @@ export const configureProxyConfigs = (appHostname, appProtocol) => { targetHost: config.host, appProtocol, appHostname, - caching: true + caching: true, + siteId: null // No auth for caching proxy }) }) localDevLog('') diff --git a/packages/pwa-kit-runtime/src/utils/ssr-server/utils.js b/packages/pwa-kit-runtime/src/utils/ssr-server/utils.js index 4e05f67fe7..177457271e 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/utils.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/utils.js @@ -167,6 +167,23 @@ export const forEachIn = (iterable, functionRef) => { }) } +/** + * Check if the target host is a Salesforce Commerce API domain + * @param {string} targetHost - The target host (may include port, e.g., "host.com:443") + * @returns {boolean} True if it's an SCAPI domain + */ +export const isScapiDomain = (targetHost) => { + if (!targetHost) return false + + // Remove port if present (handle both IPv4 and domain formats) + // Example: "abc-001.api.commercecloud.salesforce.com:443" -> "abc-001.api.commercecloud.salesforce.com" + const hostname = targetHost.split(':')[0] + + // Check if it matches *.api.commercecloud.salesforce.com pattern + // SCAPI domains always have an instance identifier subdomain (e.g., abc-001, kv7kzm78) + return hostname.endsWith('.api.commercecloud.salesforce.com') +} + /** * Log an internal MRT error. * diff --git a/packages/pwa-kit-runtime/src/utils/ssr-server/utils.test.js b/packages/pwa-kit-runtime/src/utils/ssr-server/utils.test.js index fe337bad6a..ee01032a41 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/utils.test.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/utils.test.js @@ -230,6 +230,25 @@ describe('Type checking utility functions', () => { }) }) +describe('isScapiDomain', () => { + test('should return true for valid SCAPI domains with instance identifier', () => { + expect(utils.isScapiDomain('abc-001.api.commercecloud.salesforce.com')).toBe(true) + expect(utils.isScapiDomain('kv7kzm78.api.commercecloud.salesforce.com:8080')).toBe(true) + }) + + test('should return false for non-SCAPI domains', () => { + expect(utils.isScapiDomain('example.com')).toBe(false) + expect(utils.isScapiDomain('commercecloud.salesforce.com')).toBe(false) + expect(utils.isScapiDomain('localhost:3000')).toBe(false) + }) + + test('should return false for null, undefined, or empty string', () => { + expect(utils.isScapiDomain(null)).toBe(false) + expect(utils.isScapiDomain(undefined)).toBe(false) + expect(utils.isScapiDomain('')).toBe(false) + }) +}) + describe('logMRTError', () => { let consoleErrorSpy diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx index 7880c28341..ef689b9f35 100644 --- a/packages/template-retail-react-app/app/components/_app-config/index.jsx +++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx @@ -107,10 +107,15 @@ const AppConfig = ({children, locals = {}}) => { defaultDnt={DEFAULT_DNT_STATE} // Set 'enablePWAKitPrivateClient' to true to use SLAS private client login flows. // Make sure to also enable useSLASPrivateClient in ssr.js when enabling this setting. - enablePWAKitPrivateClient={false} + enablePWAKitPrivateClient={true} privateClientProxyEndpoint={slasPrivateClientProxyEndpoint} // Uncomment 'hybridAuthEnabled' if the current site has Hybrid Auth enabled. Do NOT set this flag for hybrid storefronts using Plugin SLAS. // hybridAuthEnabled={true} + useHttpOnlySessionCookies={ + typeof window !== 'undefined' + ? window.__MRT_DISABLE_HTTPONLY_SESSION_COOKIES__ === 'false' + : process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES === 'false' + } logger={createLogger({packageName: 'commerce-sdk-react'})} > diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 98bb6b9aee..cb0901475a 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -51,7 +51,7 @@ const options = { // Set this to false if using a SLAS public client // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set - useSLASPrivateClient: false, + useSLASPrivateClient: true, // If you wish to use additional SLAS endpoints that require private clients, // customize this regex to include the additional endpoints the custom SLAS diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 66d7744056..749417fb2c 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -57,7 +57,7 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', + clientId: 'e14057f3-13f7-4df4-8ddb-d182775a5646', organizationId: 'f_ecom_zzrf_001', shortCode: '8o7m175y', siteId: 'RefArchGlobal' From 0f689e14226d2f543f4c03860914127c14357c2d Mon Sep 17 00:00:00 2001 From: unandyala Date: Thu, 19 Feb 2026 17:01:12 -0600 Subject: [PATCH 02/15] add changelog --- packages/commerce-sdk-react/CHANGELOG.md | 1 + packages/pwa-kit-runtime/CHANGELOG.md | 2 +- packages/template-retail-react-app/CHANGELOG.md | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index 184ca75c6a..3e5021ba61 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -1,5 +1,6 @@ ## v5.1.0-dev - Add Node 24 support. Drop Node 16 support. [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) +- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) ## v5.0.0 (Feb 12, 2026) - Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552) diff --git a/packages/pwa-kit-runtime/CHANGELOG.md b/packages/pwa-kit-runtime/CHANGELOG.md index ae90433187..bde8a9b919 100644 --- a/packages/pwa-kit-runtime/CHANGELOG.md +++ b/packages/pwa-kit-runtime/CHANGELOG.md @@ -1,6 +1,6 @@ ## v3.17.0-dev - Add Node 24 support. Migrate deprecated Node.js `url.parse()` and `url.format()` to the WHATWG `URL` [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) -- Add HttpOnly session cookies for SLAS private client proxy: when `MRT_DISABLE_HTTPONLY_SESSION_COOKIES` is not `'true'`, token responses are intercepted and session tokens are set as HttpOnly cookies; token fields (access_token, idp_access_token, refresh_token) are stripped from the response body. The client continues to get expires_in and refresh_token_expires_in from the response body. This runs before `onSLASPrivateProxyRes`; custom callbacks receive the sanitized response and should read from response headers (e.g. Set-Cookie) rather than the body when using HttpOnly session cookies. An error is thrown if `siteId` is missing in commerce API parameters. +- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) ## v3.16.0 (Feb 12, 2026) - Migrate AWS SDK from v2 to v3 [#3566](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3566) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index e725c125a5..f79c5d564a 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,5 +1,6 @@ ## v9.1.0-dev - Add Node 24 support. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) +- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) ## v9.0.0 (Feb 12, 2026) - [Feature] One Click Checkout (in Developer Preview) [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552) From cbdfe90d686a4baa2ffff5d9c0a7534f507f6200 Mon Sep 17 00:00:00 2001 From: unandyala Date: Thu, 19 Feb 2026 17:24:49 -0600 Subject: [PATCH 03/15] fix lint errors --- packages/commerce-sdk-react/src/provider.tsx | 3 ++- packages/pwa-kit-dev/CHANGELOG.md | 1 + .../src/ssr/server/build-remote-server.test.js | 7 +++---- .../utils/ssr-server/configure-proxy.basic.test.js | 11 ++++++++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx index 834e7e1952..d94a466fe1 100644 --- a/packages/commerce-sdk-react/src/provider.tsx +++ b/packages/commerce-sdk-react/src/provider.tsx @@ -157,7 +157,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { // When HttpOnly cookies are enabled, ensure fetch credentials allow cookies to be sent. // Override 'omit' or unset to 'same-origin'; keep 'same-origin' or 'include' as-is. const effectiveFetchOptions = - useHttpOnlySessionCookies && (!fetchOptions?.credentials || fetchOptions.credentials === 'omit') + useHttpOnlySessionCookies && + (!fetchOptions?.credentials || fetchOptions.credentials === 'omit') ? {...fetchOptions, credentials: 'same-origin' as RequestCredentials} : fetchOptions diff --git a/packages/pwa-kit-dev/CHANGELOG.md b/packages/pwa-kit-dev/CHANGELOG.md index 1646b9366a..1921bbf036 100644 --- a/packages/pwa-kit-dev/CHANGELOG.md +++ b/packages/pwa-kit-dev/CHANGELOG.md @@ -1,6 +1,7 @@ ## v3.17.0-dev - Add Node 24 support, remove legacy `url` module import. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) - Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635) +- Fix issue to correctly set the environment variable `MRT_DISABLE_HTTPONLY_SESSION_COOKIES` [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) ## v3.16.0 (Feb 12, 2026) diff --git a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js index 6a5f482f9d..2b197ebc84 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js +++ b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js @@ -365,7 +365,6 @@ describe('SLAS private proxy', () => { mockSlasServerInstance.close() } }) - }) describe('HttpOnly session cookies', () => { @@ -583,8 +582,8 @@ describe('HttpOnly session cookies', () => { expect(response.headers['set-cookie']).toBeDefined() const cookies = response.headers['set-cookie'] - expect(cookies.some(c => c.includes('access_token_testsite'))).toBe(true) - expect(cookies.some(c => c.includes('cc-nx-g_testsite'))).toBe(true) + expect(cookies.some((c) => c.includes('access_token_testsite'))).toBe(true) + expect(cookies.some((c) => c.includes('cc-nx-g_testsite'))).toBe(true) } finally { mockSlasServerInstance.close() } @@ -692,7 +691,7 @@ describe('HttpOnly session cookies', () => { expect(response.headers['set-cookie']).toBeDefined() const cookies = response.headers['set-cookie'] - expect(cookies.some(c => c.includes('access_token_testsite'))).toBe(true) + expect(cookies.some((c) => c.includes('access_token_testsite'))).toBe(true) } finally { mockSlasServerInstance.close() } diff --git a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js index eb23ea3baa..69afde131b 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js @@ -7,7 +7,6 @@ import { applyProxyRequestHeaders, applyProxyRequestAuthHeader, - ALLOWED_CACHING_PROXY_REQUEST_METHODS, configureProxy } from './configure-proxy' import * as ssrProxying from '../ssr-proxying' @@ -122,7 +121,10 @@ describe('applyProxyRequestAuthHeader', () => { slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ }) - expect(proxyRequest.setHeader).toHaveBeenCalledWith('authorization', 'Bearer test-access-token') + expect(proxyRequest.setHeader).toHaveBeenCalledWith( + 'authorization', + 'Bearer test-access-token' + ) }) it('applies Bearer token for SLAS logout endpoint', () => { @@ -146,7 +148,10 @@ describe('applyProxyRequestAuthHeader', () => { slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ }) - expect(proxyRequest.setHeader).toHaveBeenCalledWith('authorization', 'Bearer test-access-token') + expect(proxyRequest.setHeader).toHaveBeenCalledWith( + 'authorization', + 'Bearer test-access-token' + ) }) it('does not apply Bearer token for SLAS token endpoint', () => { From fd41b17bed0f81006f8866d5d842e88f092abc50 Mon Sep 17 00:00:00 2001 From: unandyala Date: Thu, 19 Feb 2026 18:16:52 -0600 Subject: [PATCH 04/15] fix lint errors --- .../assets/bootstrap/js/config/default.js.hbs | 4 ++-- .../app/components/_app-config/index.jsx | 2 +- packages/template-retail-react-app/app/ssr.js | 2 +- packages/template-retail-react-app/config/default.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs index cc68dca43f..b3fc98b3d4 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/config/default.js.hbs @@ -187,8 +187,8 @@ module.exports = { // Additional parameters that configure Express app behavior. ssrParameters: { ssrFunctionNodeVersion: '24.x', -// Store the session cookies as HttpOnly for enhanced security. -disableHttpOnlySessionCookies: false, + // Store the session cookies as HttpOnly for enhanced security. + disableHttpOnlySessionCookies: false, proxyConfigs: [ { host: '{{answers.project.commerce.shortCode}}.api.commercecloud.salesforce.com', diff --git a/packages/template-retail-react-app/app/components/_app-config/index.jsx b/packages/template-retail-react-app/app/components/_app-config/index.jsx index ef689b9f35..e766d8e891 100644 --- a/packages/template-retail-react-app/app/components/_app-config/index.jsx +++ b/packages/template-retail-react-app/app/components/_app-config/index.jsx @@ -107,7 +107,7 @@ const AppConfig = ({children, locals = {}}) => { defaultDnt={DEFAULT_DNT_STATE} // Set 'enablePWAKitPrivateClient' to true to use SLAS private client login flows. // Make sure to also enable useSLASPrivateClient in ssr.js when enabling this setting. - enablePWAKitPrivateClient={true} + enablePWAKitPrivateClient={false} privateClientProxyEndpoint={slasPrivateClientProxyEndpoint} // Uncomment 'hybridAuthEnabled' if the current site has Hybrid Auth enabled. Do NOT set this flag for hybrid storefronts using Plugin SLAS. // hybridAuthEnabled={true} diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index cb0901475a..98bb6b9aee 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -51,7 +51,7 @@ const options = { // Set this to false if using a SLAS public client // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set - useSLASPrivateClient: true, + useSLASPrivateClient: false, // If you wish to use additional SLAS endpoints that require private clients, // customize this regex to include the additional endpoints the custom SLAS diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 749417fb2c..66d7744056 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -57,7 +57,7 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: 'e14057f3-13f7-4df4-8ddb-d182775a5646', + clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', organizationId: 'f_ecom_zzrf_001', shortCode: '8o7m175y', siteId: 'RefArchGlobal' From b71897f031ad2de61d1f84d980b58699f9d27b10 Mon Sep 17 00:00:00 2001 From: unandyala Date: Thu, 19 Feb 2026 18:26:49 -0600 Subject: [PATCH 05/15] fix changelog --- packages/pwa-kit-create-app/CHANGELOG.md | 2 ++ packages/pwa-kit-dev/CHANGELOG.md | 6 ++++-- packages/pwa-kit-runtime/CHANGELOG.md | 4 +++- packages/template-retail-react-app/CHANGELOG.md | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index 1eb5e8a0e1..539f7e8655 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -1,3 +1,5 @@ +## [Unreleased] + ## v3.17.0-dev - Clear verdaccio npm cache during project generation [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) - Add Node 24 support, remove legacy `url` module import. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) diff --git a/packages/pwa-kit-dev/CHANGELOG.md b/packages/pwa-kit-dev/CHANGELOG.md index 1921bbf036..d6c391d0a6 100644 --- a/packages/pwa-kit-dev/CHANGELOG.md +++ b/packages/pwa-kit-dev/CHANGELOG.md @@ -1,8 +1,10 @@ -## v3.17.0-dev -- Add Node 24 support, remove legacy `url` module import. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) +## [Unreleased] - Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635) - Fix issue to correctly set the environment variable `MRT_DISABLE_HTTPONLY_SESSION_COOKIES` [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) +## v3.17.0-dev +- Add Node 24 support, remove legacy `url` module import. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) + ## v3.16.0 (Feb 12, 2026) ## v3.15.0 (Dec 17, 2025) diff --git a/packages/pwa-kit-runtime/CHANGELOG.md b/packages/pwa-kit-runtime/CHANGELOG.md index bde8a9b919..5a8aa5f6be 100644 --- a/packages/pwa-kit-runtime/CHANGELOG.md +++ b/packages/pwa-kit-runtime/CHANGELOG.md @@ -1,6 +1,8 @@ +## [Unreleased] +- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) + ## v3.17.0-dev - Add Node 24 support. Migrate deprecated Node.js `url.parse()` and `url.format()` to the WHATWG `URL` [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) -- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) ## v3.16.0 (Feb 12, 2026) - Migrate AWS SDK from v2 to v3 [#3566](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3566) diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index f79c5d564a..0ea31cf853 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,6 +1,8 @@ +## [Unreleased] +- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) + ## v9.1.0-dev - Add Node 24 support. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) -- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) ## v9.0.0 (Feb 12, 2026) - [Feature] One Click Checkout (in Developer Preview) [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552) From 1184547ab1ecd7ed45c93f6a86520b4aa7cae133 Mon Sep 17 00:00:00 2001 From: unandyala Date: Thu, 19 Feb 2026 19:17:56 -0600 Subject: [PATCH 06/15] fix changelog --- packages/template-retail-react-app/config/default.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 66d7744056..902508ee07 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -107,7 +107,7 @@ module.exports = { ssrParameters: { ssrFunctionNodeVersion: '24.x', // Store the session cookies as HttpOnly for enhanced security. - disableHttpOnlySessionCookies: false, + disableHttpOnlySessionCookies: true, proxyConfigs: [ { host: 'kv7kzm78.api.commercecloud.salesforce.com', From 88427d971839173097a3a799586b5c5603b24470 Mon Sep 17 00:00:00 2001 From: unandyala Date: Thu, 19 Feb 2026 21:47:00 -0600 Subject: [PATCH 07/15] fix changelog --- packages/commerce-sdk-react/CHANGELOG.md | 4 +++- packages/commerce-sdk-react/src/auth/index.ts | 2 +- packages/commerce-sdk-react/src/provider.tsx | 1 - packages/pwa-kit-create-app/CHANGELOG.md | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index 3e5021ba61..b37fe11704 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -1,6 +1,8 @@ +## [Unreleased] +- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) + ## v5.1.0-dev - Add Node 24 support. Drop Node 16 support. [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) -- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) ## v5.0.0 (Feb 12, 2026) - Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552) diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index e14c64ea85..fcb8f3a3c2 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -54,7 +54,7 @@ interface AuthConfig extends ApiClientConfigParams { refreshTokenRegisteredCookieTTL?: number refreshTokenGuestCookieTTL?: number hybridAuthEnabled?: boolean - /** When true, token response may be sanitized (tokens in HttpOnly cookies); only set non-token fields and use access_token_expires_at for expiry. */ + /** When true, session tokens are set as HttpOnly cookies */ useHttpOnlySessionCookies?: boolean } diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx index d94a466fe1..f18c07fb19 100644 --- a/packages/commerce-sdk-react/src/provider.tsx +++ b/packages/commerce-sdk-react/src/provider.tsx @@ -155,7 +155,6 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { const configLogger = logger || console // When HttpOnly cookies are enabled, ensure fetch credentials allow cookies to be sent. - // Override 'omit' or unset to 'same-origin'; keep 'same-origin' or 'include' as-is. const effectiveFetchOptions = useHttpOnlySessionCookies && (!fetchOptions?.credentials || fetchOptions.credentials === 'omit') diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index 539f7e8655..fd4f0bae3c 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -1,4 +1,5 @@ ## [Unreleased] +- Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635) ## v3.17.0-dev - Clear verdaccio npm cache during project generation [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) From 7ab5c310564a38d90ada99f7b97dd3d5cb37f75c Mon Sep 17 00:00:00 2001 From: unandyala Date: Thu, 19 Feb 2026 21:53:10 -0600 Subject: [PATCH 08/15] fix doc --- .../pwa-kit-runtime/src/ssr/server/build-remote-server.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js index 9eda34da4c..95a3d9dbb0 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js +++ b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js @@ -122,8 +122,8 @@ function getRefreshTokenCookieTTL(refreshTokenExpiresInSLASValue, isGuest, optio } /** - * When HttpOnly session cookies are enabled: set tokens as HttpOnly cookies, TTLs as non-HttpOnly, - * strip token fields from body, and remove upstream Set-Cookie so we control all cookies. + * When HttpOnly session cookies are enabled: set tokens as HttpOnly cookies, + * strip token fields from body, and append our Set-Cookie headers (preserving upstream cookies). * @private */ function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options) { From 2980ddfb718bf360093d410af819320e11054d2d Mon Sep 17 00:00:00 2001 From: unandyala Date: Thu, 19 Feb 2026 22:14:20 -0600 Subject: [PATCH 09/15] fix doc --- packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js index 95a3d9dbb0..e047d19e5b 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js +++ b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js @@ -1210,7 +1210,7 @@ export const RemoteServerFactory = { if (typeof options.onSLASPrivateProxyRes === 'function') { try { const customBuffer = options.onSLASPrivateProxyRes( - responseBuffer, + workingBuffer, proxyRes, req, res From 1e9bafef3add54ee5018fc0f4379fa38eb777a4a Mon Sep 17 00:00:00 2001 From: unandyala Date: Fri, 20 Feb 2026 19:32:54 -0600 Subject: [PATCH 10/15] small refactor --- .../src/ssr/server/build-remote-server.js | 162 +-------- .../ssr/server/build-remote-server.test.js | 7 +- .../src/ssr/server/constants.js | 8 + .../src/ssr/server/process-token-response.js | 229 ++++++++++++ .../ssr/server/process-token-response.test.js | 334 ++++++++++++++++++ 5 files changed, 582 insertions(+), 158 deletions(-) create mode 100644 packages/pwa-kit-runtime/src/ssr/server/process-token-response.js create mode 100644 packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js diff --git a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js index e047d19e5b..16b98fe65e 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js +++ b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js @@ -13,7 +13,9 @@ import { CACHE_CONTROL, NO_CACHE, X_ENCODED_HEADERS, - CONTENT_SECURITY_POLICY + CONTENT_SECURITY_POLICY, + SLAS_TOKEN_RESPONSE_ENDPOINTS, + SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN } from './constants' import { catchAndLog, @@ -32,12 +34,7 @@ import dns from 'dns' import express from 'express' import {PersistentCache} from '../../utils/ssr-cache' import merge from 'merge-descriptors' -import { - Headers, - X_HEADERS_TO_REMOVE_ORIGIN, - X_MOBIFY_REQUEST_CLASS, - cookieAsString -} from '../../utils/ssr-proxying' +import {Headers, X_HEADERS_TO_REMOVE_ORIGIN, X_MOBIFY_REQUEST_CLASS} from '../../utils/ssr-proxying' import assert from 'assert' import semver from 'semver' import pkg from '../../../package.json' @@ -65,8 +62,8 @@ import {CallbackResolver} from '@h4ad/serverless-adapter/lib/resolvers/callback' import {ApiGatewayV1Adapter} from '@h4ad/serverless-adapter/lib/adapters/aws' import {ExpressFramework} from '@h4ad/serverless-adapter/lib/frameworks/express' import {is as typeis} from 'type-is' -import {jwtDecode} from 'jwt-decode' import {getConfig} from '../../utils/ssr-config' +import {applyHttpOnlySessionCookies} from './process-token-response' /** * An Array of mime-types (Content-Type values) that are considered @@ -96,151 +93,6 @@ export const isBinary = (headers) => { return isContentTypeBinary(headers) } -// Refresh token cookie TTL defaults (seconds). Must stay in sync with commerce-sdk-react auth constants. -const DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL = 30 * 24 * 60 * 60 -const DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = 90 * 24 * 60 * 60 - -/** - * Computes refresh token cookie TTL in seconds. Same logic as Auth.getRefreshTokenCookieTTLValue in commerce-sdk-react: - * 1. Override value (if valid), 2. SLAS response value, 3. Default (guest or registered). - * Used when setting HttpOnly refresh token cookies. Keep in sync with commerce-sdk-react auth. - * @private - */ -function getRefreshTokenCookieTTL(refreshTokenExpiresInSLASValue, isGuest, options = {}) { - const overrideValue = isGuest - ? options.refreshTokenGuestCookieTTL - : options.refreshTokenRegisteredCookieTTL - const defaultValue = isGuest - ? DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL - : DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL - const isOverrideValid = - typeof overrideValue === 'number' && overrideValue > 0 && overrideValue <= defaultValue - if (!isOverrideValid && overrideValue !== undefined) { - logger.warn('You are attempting to use an invalid refresh token TTL value.') - } - return isOverrideValid ? overrideValue : refreshTokenExpiresInSLASValue || defaultValue -} - -/** - * When HttpOnly session cookies are enabled: set tokens as HttpOnly cookies, - * strip token fields from body, and append our Set-Cookie headers (preserving upstream cookies). - * @private - */ -function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options) { - const siteId = options.mobify?.app?.commerceAPI?.parameters?.siteId - if (!siteId || typeof siteId !== 'string' || siteId.trim() === '') { - throw new Error( - 'HttpOnly session cookies are enabled but siteId is missing. ' + - 'Set mobify.app.commerceAPI.parameters.siteId in your app config.' - ) - } - - let parsed - try { - parsed = JSON.parse(responseBuffer.toString('utf8')) - } catch { - return responseBuffer - } - - const cookieOptsBase = { - path: '/', - secure: true, - sameSite: 'lax', - httpOnly: true - } - const appendCookie = (cookieObj) => { - res.append(SET_COOKIE, cookieAsString(cookieObj)) - } - - // Add our cookies (append Set-Cookie); same names override, other upstream cookies are preserved - const siteIdTrimmed = siteId.trim() - - // Access token - const accessToken = parsed.access_token - const expiresInSeconds = typeof parsed.expires_in === 'number' ? parsed.expires_in : 1800 - if (accessToken) { - const accessExpires = new Date(Date.now() + expiresInSeconds * 1000) - appendCookie({ - name: `access_token_${siteIdTrimmed}`, - value: accessToken, - ...cookieOptsBase, - expires: accessExpires - }) - } - - // Decode access token once for access_token_expires_at and guest/registered (same as commerce-sdk-react parseSlasJWT) - let accessTokenPayload = null - if (accessToken) { - try { - accessTokenPayload = jwtDecode(accessToken) - } catch (error) { - throw new Error(`Failed to decode access token JWT: ${error.message || error}. `) - } - } - // access_token_expires_at: Unix timestamp (seconds) when the access token expires; use JWT iat when available; added to response body so client can store in localStorage (no cookie expiry to manage) - let accessTokenExpiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds - if (accessTokenPayload && typeof accessTokenPayload.iat === 'number') { - accessTokenExpiresAt = accessTokenPayload.iat + expiresInSeconds - } - - // IDP access token - const idpAccessToken = parsed.idp_access_token - if (idpAccessToken) { - const expiresInSeconds = typeof parsed.expires_in === 'number' ? parsed.expires_in : 1800 - const idpExpires = new Date(Date.now() + expiresInSeconds * 1000) - appendCookie({ - name: `idp_access_token_${siteIdTrimmed}`, - value: idpAccessToken, - ...cookieOptsBase, - expires: idpExpires - }) - } - - // Refresh token: set only the cookie for this user type (cc-nx-g = guest, cc-nx = registered); isGuest from JWT isb same as commerce-sdk-react parseSlasJWT - const refreshToken = parsed.refresh_token - let isGuest = true - let uido = null - if (accessTokenPayload && typeof accessTokenPayload.isb === 'string') { - const isbParts = accessTokenPayload.isb.split('::') - isGuest = isbParts[1] === 'upn:Guest' - const uidoPart = isbParts[0].split('uido:')[1] - if (uidoPart) uido = uidoPart - } - const refreshTTL = getRefreshTokenCookieTTL(parsed.refresh_token_expires_in, isGuest) - const refreshExpires = new Date(Date.now() + refreshTTL * 1000) - if (refreshToken) { - const refreshCookieName = isGuest ? `cc-nx-g_${siteIdTrimmed}` : `cc-nx_${siteIdTrimmed}` - appendCookie({ - name: refreshCookieName, - value: refreshToken, - ...cookieOptsBase, - expires: refreshExpires - }) - } - - // uido: IDP origin from JWT (e.g. "slas", "ecom"); non-HttpOnly so client can read it for useCustomerType/isExternal - if (uido) { - const accessExpires = new Date(Date.now() + expiresInSeconds * 1000) - appendCookie({ - name: `uido_${siteIdTrimmed}`, - value: uido, - path: '/', - secure: true, - sameSite: 'lax', - httpOnly: false, - expires: accessExpires - }) - } - - // Strip from body only the fields set as HttpOnly cookies; add access_token_expires_at so client can store for expiry checks (uido is in non-HttpOnly cookie for client to read) - const stripped = {...parsed} - delete stripped.access_token - delete stripped.idp_access_token - delete stripped.refresh_token - stripped.access_token_expires_at = accessTokenExpiresAt - return Buffer.from(JSON.stringify(stripped), 'utf8') -} - /** * Environment variables that must be set for the Express app to run remotely. * @@ -315,13 +167,13 @@ export const RemoteServerFactory = { // A regex for identifying which SLAS endpoints return tokens (access_token, refresh_token) // in the response body. Used to determine which responses should have HttpOnly session // cookies applied when that feature is enabled. Users can override this in ssr.js. - tokenResponseEndpoints: /\/oauth2\/(token|passwordless\/token)$/, + tokenResponseEndpoints: SLAS_TOKEN_RESPONSE_ENDPOINTS, // A regex for identifying which SLAS auth endpoints (/shopper/auth/) require the // shopper's access token in the Authorization header (Bearer token from HttpOnly cookie). // Most SLAS auth endpoints use Basic Auth with client credentials, but some like logout // require the shopper's Bearer token. Users can override this in ssr.js. - slasEndpointsRequiringAccessToken: /\/oauth2\/logout/, + slasEndpointsRequiringAccessToken: SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN, // Custom callback to modify the SLAS private client proxy request. This callback is invoked // after the built-in proxy request handling. Users can provide additional diff --git a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js index 2b197ebc84..c52a6fe33f 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js +++ b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js @@ -578,11 +578,11 @@ describe('HttpOnly session cookies', () => { expect(response.status).toBe(200) expect(response.body).not.toHaveProperty('access_token') expect(response.body).not.toHaveProperty('refresh_token') - expect(response.body).toHaveProperty('access_token_expires_at') expect(response.headers['set-cookie']).toBeDefined() const cookies = response.headers['set-cookie'] - expect(cookies.some((c) => c.includes('access_token_testsite'))).toBe(true) + expect(cookies.some((c) => c.includes('cc-at_testsite'))).toBe(true) + expect(cookies.some((c) => c.includes('cc-at-expires-at_testsite'))).toBe(true) expect(cookies.some((c) => c.includes('cc-nx-g_testsite'))).toBe(true) } finally { mockSlasServerInstance.close() @@ -691,7 +691,8 @@ describe('HttpOnly session cookies', () => { expect(response.headers['set-cookie']).toBeDefined() const cookies = response.headers['set-cookie'] - expect(cookies.some((c) => c.includes('access_token_testsite'))).toBe(true) + expect(cookies.some((c) => c.includes('cc-at_testsite'))).toBe(true) + expect(cookies.some((c) => c.includes('cc-at-expires-at_testsite'))).toBe(true) } finally { mockSlasServerInstance.close() } diff --git a/packages/pwa-kit-runtime/src/ssr/server/constants.js b/packages/pwa-kit-runtime/src/ssr/server/constants.js index 80d1568822..654adc8b80 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/constants.js +++ b/packages/pwa-kit-runtime/src/ssr/server/constants.js @@ -26,3 +26,11 @@ export const STRICT_TRANSPORT_SECURITY = 'strict-transport-security' /** * @deprecated Use ssr-namespace-paths.slasPrivateProxyPath instead */ export const SLAS_CUSTOM_PROXY_PATH = '/mobify/slas/private' + +// Default regex patterns for SLAS token endpoints, used for setting httpOnly session cookies +// Users can override these in their project's ssr.js options. +export const SLAS_TOKEN_RESPONSE_ENDPOINTS = /\/oauth2\/(token|passwordless\/token)$/ + +// Default regex patterns for SLAS endpoints that need access token in authorization header, used when httpOnly session cookies are enabled +// Users can override these in their project's ssr.js options. +export const SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN = /\/oauth2\/logout/ diff --git a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js new file mode 100644 index 0000000000..31412d0a2f --- /dev/null +++ b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2022, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {jwtDecode} from 'jwt-decode' +import {cookieAsString} from '../../utils/ssr-proxying' +import {SET_COOKIE} from './constants' +import logger from '../../utils/logger-instance' + +// Refresh token cookie TTL defaults (seconds). Must stay in sync with commerce-sdk-react auth constants. +const DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL = 30 * 24 * 60 * 60 +const DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL = 90 * 24 * 60 * 60 + +/** + * Computes refresh token cookie TTL in seconds. Same logic as Auth.getRefreshTokenCookieTTLValue in commerce-sdk-react: + * 1. Override value (if valid), 2. SLAS response value, 3. Default (guest or registered). + * Used when setting HttpOnly refresh token cookies. Keep in sync with commerce-sdk-react auth. + * @private + */ +export function getRefreshTokenCookieTTL(refreshTokenExpiresInSLASValue, isGuest, options = {}) { + const overrideValue = isGuest + ? options.refreshTokenGuestCookieTTL + : options.refreshTokenRegisteredCookieTTL + const defaultValue = isGuest + ? DEFAULT_SLAS_REFRESH_TOKEN_GUEST_TTL + : DEFAULT_SLAS_REFRESH_TOKEN_REGISTERED_TTL + const isOverrideValid = + typeof overrideValue === 'number' && overrideValue > 0 && overrideValue <= defaultValue + if (!isOverrideValid && overrideValue !== undefined) { + logger.warn('You are attempting to use an invalid refresh token TTL value.') + } + return isOverrideValid ? overrideValue : refreshTokenExpiresInSLASValue || defaultValue +} + +/** + * Decodes the SLAS access token JWT, extracts claims, and sets non-HttpOnly metadata cookies + * (expires-at, dnt, uido) so the client can read them. Same field extraction as + * commerce-sdk-react parseSlasJWT. + * + * Returns {isGuest} for the caller to determine the refresh token cookie name. + * @private + */ +function setTokenClaimCookies(res, siteId, accessToken, expiresInSeconds) { + let payload + try { + payload = jwtDecode(accessToken) + } catch (error) { + throw new Error(`Failed to decode access token JWT: ${error.message || error}. `) + } + + const accessExpires = new Date(Date.now() + expiresInSeconds * 1000) + + // Expiry timestamp — use JWT iat when available (non-HttpOnly so client can check expiry) + let expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds + if (typeof payload.iat === 'number') { + expiresAt = payload.iat + expiresInSeconds + } + res.append( + SET_COOKIE, + cookieAsString({ + name: `cc-at-expires-at_${siteId}`, + value: String(expiresAt), + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: false, + expires: accessExpires + }) + ) + + // Do-not-track flag from JWT (non-HttpOnly so client can read it) + if (payload.dnt !== undefined) { + res.append( + SET_COOKIE, + cookieAsString({ + name: `cc-at-dnt_${siteId}`, + value: String(payload.dnt), + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: false, + expires: accessExpires + }) + ) + } + + // Extract isGuest and uido from JWT isb claim + let isGuest = true + let uido = null + if (typeof payload.isb === 'string') { + const isbParts = payload.isb.split('::') + isGuest = isbParts[1] === 'upn:Guest' + const uidoPart = isbParts[0].split('uido:')[1] + if (uidoPart) uido = uidoPart + } + + // uido: IDP origin (e.g. "slas", "ecom"); non-HttpOnly so client can read for useCustomerType/isExternal + if (uido) { + res.append( + SET_COOKIE, + cookieAsString({ + name: `uido_${siteId}`, + value: uido, + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: false, + expires: accessExpires + }) + ) + } + + return {isGuest} +} + +/** + * Sets the IDP access token as an HttpOnly cookie. + * @private + */ +function setIdpAccessTokenCookie(res, siteId, idpAccessToken, expiresInSeconds) { + const idpExpires = new Date(Date.now() + expiresInSeconds * 1000) + res.append( + SET_COOKIE, + cookieAsString({ + name: `idp_access_token_${siteId}`, + value: idpAccessToken, + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: true, + expires: idpExpires + }) + ) +} + +/** + * Sets the refresh token as an HttpOnly cookie. Cookie name depends on guest vs registered user. + * @private + */ +function setRefreshTokenCookie(res, siteId, refreshToken, refreshTokenExpiresIn, isGuest) { + const refreshTTL = getRefreshTokenCookieTTL(refreshTokenExpiresIn, isGuest) + const refreshExpires = new Date(Date.now() + refreshTTL * 1000) + const refreshCookieName = isGuest ? `cc-nx-g_${siteId}` : `cc-nx_${siteId}` + + res.append( + SET_COOKIE, + cookieAsString({ + name: refreshCookieName, + value: refreshToken, + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: true, + expires: refreshExpires + }) + ) +} + +/** + * When HttpOnly session cookies are enabled: set tokens as HttpOnly cookies, + * strip token fields from body, and append our Set-Cookie headers (preserving upstream cookies). + * @private + */ +export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options) { + const siteId = options.mobify?.app?.commerceAPI?.parameters?.siteId + if (!siteId || typeof siteId !== 'string' || siteId.trim() === '') { + throw new Error( + 'HttpOnly session cookies are enabled but siteId is missing. ' + + 'Set mobify.app.commerceAPI.parameters.siteId in your app config.' + ) + } + + let parsed + try { + parsed = JSON.parse(responseBuffer.toString('utf8')) + } catch { + return responseBuffer + } + + const site = siteId.trim() + const expiresInSeconds = typeof parsed.expires_in === 'number' ? parsed.expires_in : 1800 + + // Decode JWT, set metadata cookies (expires-at, dnt, uido), get isGuest + let isGuest = true + if (parsed.access_token) { + // Access token (HttpOnly) + const accessExpires = new Date(Date.now() + expiresInSeconds * 1000) + res.append( + SET_COOKIE, + cookieAsString({ + name: `cc-at_${site}`, + value: parsed.access_token, + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: true, + expires: accessExpires + }) + ) + + const claims = setTokenClaimCookies(res, site, parsed.access_token, expiresInSeconds) + isGuest = claims.isGuest + } + + // IDP access token + if (parsed.idp_access_token) { + setIdpAccessTokenCookie(res, site, parsed.idp_access_token, expiresInSeconds) + } + + // Refresh token + if (parsed.refresh_token) { + setRefreshTokenCookie( + res, + site, + parsed.refresh_token, + parsed.refresh_token_expires_in, + isGuest + ) + } + + // Strip token fields from body so they are not exposed to the client + const stripped = {...parsed} + delete stripped.access_token + delete stripped.idp_access_token + delete stripped.refresh_token + return Buffer.from(JSON.stringify(stripped), 'utf8') +} diff --git a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js new file mode 100644 index 0000000000..e4d0c8f07e --- /dev/null +++ b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js @@ -0,0 +1,334 @@ +/* + * Copyright (c) 2022, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {getRefreshTokenCookieTTL, applyHttpOnlySessionCookies} from './process-token-response' +import {parse as parseSetCookie} from 'set-cookie-parser' + +jest.mock('../../utils/logger-instance', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + info: jest.fn(), + error: jest.fn() + } +})) + +import logger from '../../utils/logger-instance' + +function makeJWT(payload) { + const header = Buffer.from(JSON.stringify({alg: 'HS256', typ: 'JWT'})).toString('base64url') + const payloadPart = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${header}.${payloadPart}.sig` +} + +function makeOptions(siteId = 'testsite') { + return { + mobify: { + app: { + commerceAPI: { + parameters: {siteId} + } + } + } + } +} + +function makeRes() { + const cookies = [] + return { + cookies, + append: jest.fn((header, value) => { + cookies.push(value) + }) + } +} + +function makeResponseBuffer(body) { + return Buffer.from(JSON.stringify(body), 'utf8') +} + +function parseCookie(cookieStr) { + return parseSetCookie(cookieStr)[0] +} + +describe('getRefreshTokenCookieTTL', () => { + const GUEST_DEFAULT = 30 * 24 * 60 * 60 + const REGISTERED_DEFAULT = 90 * 24 * 60 * 60 + + beforeEach(() => { + jest.clearAllMocks() + }) + + test('returns SLAS value for guest when no override', () => { + expect(getRefreshTokenCookieTTL(12345, true)).toBe(12345) + }) + + test('returns SLAS value for registered when no override', () => { + expect(getRefreshTokenCookieTTL(54321, false)).toBe(54321) + }) + + test('returns guest default when no SLAS value and no override', () => { + expect(getRefreshTokenCookieTTL(undefined, true)).toBe(GUEST_DEFAULT) + }) + + test('returns registered default when no SLAS value and no override', () => { + expect(getRefreshTokenCookieTTL(undefined, false)).toBe(REGISTERED_DEFAULT) + }) + + test('uses valid guest override', () => { + const ttl = 1000 + expect(getRefreshTokenCookieTTL(12345, true, {refreshTokenGuestCookieTTL: ttl})).toBe(ttl) + }) + + test('uses valid registered override', () => { + const ttl = 1000 + expect( + getRefreshTokenCookieTTL(12345, false, {refreshTokenRegisteredCookieTTL: ttl}) + ).toBe(ttl) + }) + + test('rejects override exceeding default and warns', () => { + const tooLarge = GUEST_DEFAULT + 1 + const result = getRefreshTokenCookieTTL(12345, true, { + refreshTokenGuestCookieTTL: tooLarge + }) + expect(result).toBe(12345) + expect(logger.warn).toHaveBeenCalledWith( + 'You are attempting to use an invalid refresh token TTL value.' + ) + }) + + test('rejects zero override and warns', () => { + const result = getRefreshTokenCookieTTL(12345, true, {refreshTokenGuestCookieTTL: 0}) + expect(result).toBe(12345) + expect(logger.warn).toHaveBeenCalled() + }) + + test('rejects negative override and warns', () => { + const result = getRefreshTokenCookieTTL(12345, true, {refreshTokenGuestCookieTTL: -1}) + expect(result).toBe(12345) + expect(logger.warn).toHaveBeenCalled() + }) + + test('rejects non-number override and warns', () => { + const result = getRefreshTokenCookieTTL(12345, true, { + refreshTokenGuestCookieTTL: 'invalid' + }) + expect(result).toBe(12345) + expect(logger.warn).toHaveBeenCalled() + }) +}) + +describe('applyHttpOnlySessionCookies', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('throws when siteId is missing', () => { + const res = makeRes() + const buf = makeResponseBuffer({access_token: 'x'}) + expect(() => applyHttpOnlySessionCookies(buf, {}, {}, res, {mobify: {}})).toThrow( + /siteId is missing/ + ) + }) + + test('throws when siteId is empty string', () => { + const res = makeRes() + const buf = makeResponseBuffer({access_token: 'x'}) + expect(() => applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions(' '))).toThrow( + /siteId is missing/ + ) + }) + + test('returns buffer unchanged for non-JSON response', () => { + const res = makeRes() + const buf = Buffer.from('not json', 'utf8') + const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + expect(result).toBe(buf) + expect(res.append).not.toHaveBeenCalled() + }) + + test('sets all cookies and strips tokens for a guest token response', () => { + const res = makeRes() + const accessToken = makeJWT({ + iat: 1000, + isb: 'uido:ecom::upn:Guest::uidn:Guest::gcid:g1', + dnt: '1' + }) + const buf = makeResponseBuffer({ + access_token: accessToken, + idp_access_token: 'idp-token-value', + refresh_token: 'refresh-value', + expires_in: 1800, + customer_id: 'cust123' + }) + const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + + // cc-at: access token (HttpOnly) + const atCookie = parseCookie(res.cookies.find((c) => c.includes('cc-at_testsite='))) + expect(atCookie.value).toBe(accessToken) + expect(atCookie.httpOnly).toBe(true) + expect(atCookie.secure).toBe(true) + expect(atCookie.path).toBe('/') + + // cc-at-expires-at: expiry from iat (non-HttpOnly) + const expCookie = parseCookie( + res.cookies.find((c) => c.includes('cc-at-expires-at_testsite=')) + ) + expect(expCookie.value).toBe(String(1000 + 1800)) + expect(expCookie.httpOnly).toBeUndefined() + + // cc-at-dnt: do-not-track from JWT (non-HttpOnly) + const dntCookie = parseCookie( + res.cookies.find((c) => c.includes('cc-at-dnt_testsite=')) + ) + expect(dntCookie.value).toBe('1') + expect(dntCookie.httpOnly).toBeUndefined() + + // idp_access_token (HttpOnly) + const idpCookie = parseCookie( + res.cookies.find((c) => c.includes('idp_access_token_testsite=')) + ) + expect(idpCookie.value).toBe('idp-token-value') + expect(idpCookie.httpOnly).toBe(true) + + // cc-nx-g: guest refresh token (HttpOnly) + const refreshCookie = parseCookie( + res.cookies.find((c) => c.includes('cc-nx-g_testsite=')) + ) + expect(refreshCookie.value).toBe('refresh-value') + expect(refreshCookie.httpOnly).toBe(true) + + // uido (non-HttpOnly) + const uidoCookie = parseCookie(res.cookies.find((c) => c.includes('uido_testsite='))) + expect(uidoCookie.value).toBe('ecom') + expect(uidoCookie.httpOnly).toBeUndefined() + + // Should NOT have registered refresh cookie + expect(res.cookies.find((c) => c.startsWith('cc-nx_testsite='))).toBeUndefined() + + // Tokens stripped from body, other fields preserved + const body = JSON.parse(result.toString('utf8')) + expect(body).not.toHaveProperty('access_token') + expect(body).not.toHaveProperty('idp_access_token') + expect(body).not.toHaveProperty('refresh_token') + expect(body.expires_in).toBe(1800) + expect(body.customer_id).toBe('cust123') + }) + + test('sets all cookies for a registered token response', () => { + const res = makeRes() + const accessToken = makeJWT({ + iat: 2000, + isb: 'uido:ecom::upn:john@example.com::uidn:John' + }) + const buf = makeResponseBuffer({ + access_token: accessToken, + refresh_token: 'refresh-value', + expires_in: 1800 + }) + const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + + // cc-at (HttpOnly) + const atCookie = parseCookie(res.cookies.find((c) => c.includes('cc-at_testsite='))) + expect(atCookie.httpOnly).toBe(true) + + // cc-at-expires-at (non-HttpOnly) + const expCookie = parseCookie( + res.cookies.find((c) => c.includes('cc-at-expires-at_testsite=')) + ) + expect(expCookie.value).toBe(String(2000 + 1800)) + + // cc-nx: registered refresh token (HttpOnly) + const refreshCookie = parseCookie( + res.cookies.find((c) => c.includes('cc-nx_testsite=')) + ) + expect(refreshCookie.value).toBe('refresh-value') + expect(refreshCookie.httpOnly).toBe(true) + + // uido (non-HttpOnly) + const uidoCookie = parseCookie(res.cookies.find((c) => c.includes('uido_testsite='))) + expect(uidoCookie.value).toBe('ecom') + + // Should NOT have guest refresh cookie + expect(res.cookies.find((c) => c.includes('cc-nx-g_testsite='))).toBeUndefined() + + // No dnt cookie when dnt absent from JWT + expect(res.cookies.find((c) => c.includes('cc-at-dnt_testsite'))).toBeUndefined() + + // Tokens stripped from body + const body = JSON.parse(result.toString('utf8')) + expect(body).not.toHaveProperty('access_token') + expect(body).not.toHaveProperty('refresh_token') + }) + + test('omits uido cookie when uido is absent from JWT', () => { + const res = makeRes() + const accessToken = makeJWT({iat: 1000, isb: '::upn:Guest'}) + const buf = makeResponseBuffer({ + access_token: accessToken, + refresh_token: 'refresh-value', + expires_in: 1800 + }) + applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + + expect(res.cookies.find((c) => c.includes('uido_testsite'))).toBeUndefined() + }) + + test('uses Date.now fallback for cc-at-expires-at when iat is missing', () => { + const res = makeRes() + const now = Math.floor(Date.now() / 1000) + const accessToken = makeJWT({isb: 'uido:ecom::upn:Guest'}) + const buf = makeResponseBuffer({access_token: accessToken, expires_in: 900}) + applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + + const expCookie = res.cookies.find((c) => c.includes('cc-at-expires-at_testsite=')) + const expiresAt = parseInt(parseCookie(expCookie).value, 10) + expect(expiresAt).toBeGreaterThanOrEqual(now + 900 - 5) + expect(expiresAt).toBeLessThanOrEqual(now + 900 + 5) + }) + + test('throws when access token JWT is invalid', () => { + const res = makeRes() + const buf = makeResponseBuffer({access_token: 'not-a-jwt', expires_in: 1800}) + expect(() => applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions())).toThrow( + /Failed to decode access token JWT/ + ) + }) + + test('defaults expires_in to 1800 when not a number', () => { + const res = makeRes() + const accessToken = makeJWT({iat: 5000, isb: 'uido:ecom::upn:Guest'}) + const buf = makeResponseBuffer({access_token: accessToken}) + applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + + const expCookie = res.cookies.find((c) => c.includes('cc-at-expires-at_testsite=')) + const parsed = parseCookie(expCookie) + expect(parsed.value).toBe(String(5000 + 1800)) + }) + + test('handles response with no tokens (no cookies set, body returned stripped)', () => { + const res = makeRes() + const buf = makeResponseBuffer({expires_in: 1800, other_field: 'value'}) + const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + const body = JSON.parse(result.toString('utf8')) + + expect(res.cookies).toHaveLength(0) + expect(body.other_field).toBe('value') + }) + + test('trims siteId and uses trimmed value in cookie names', () => { + const res = makeRes() + const accessToken = makeJWT({iat: 1000, isb: 'uido:ecom::upn:Guest'}) + const buf = makeResponseBuffer({access_token: accessToken, expires_in: 1800}) + applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions(' mysite ')) + + const atCookie = res.cookies.find((c) => c.includes('cc-at_mysite=')) + expect(atCookie).toBeDefined() + // No leading/trailing spaces in cookie name + expect(res.cookies.find((c) => c.includes('cc-at_ mysite'))).toBeUndefined() + }) +}) From 9d46554e53ab4e1db0ef76f0bafc073f6af7439d Mon Sep 17 00:00:00 2001 From: unandyala Date: Fri, 20 Feb 2026 19:42:26 -0600 Subject: [PATCH 11/15] small refactor --- .../ssr-server/configure-proxy.basic.test.js | 38 +++++++++---------- .../src/utils/ssr-server/configure-proxy.js | 6 +-- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js index 69afde131b..ba660ccf52 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js @@ -6,7 +6,7 @@ */ import { applyProxyRequestHeaders, - applyProxyRequestAuthHeader, + applyScapiAuthHeaders, configureProxy } from './configure-proxy' import * as ssrProxying from '../ssr-proxying' @@ -94,14 +94,14 @@ describe('configureProxy ALLOWED_CACHING_PROXY_REQUEST_METHODS', () => { }) }) -describe('applyProxyRequestAuthHeader', () => { +describe('applyScapiAuthHeaders', () => { beforeEach(() => { jest.clearAllMocks() }) it('applies Bearer token for non-SLAS Shopper API endpoints', () => { utils.isScapiDomain.mockReturnValue(true) - cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'}) const proxyRequest = { setHeader: jest.fn(), @@ -109,10 +109,10 @@ describe('applyProxyRequestAuthHeader', () => { } const incomingRequest = { url: '/shopper/products/v1/products', - headers: {cookie: 'access_token_RefArch=test-access-token'} + headers: {cookie: 'cc-at_RefArch=test-access-token'} } - applyProxyRequestAuthHeader({ + applyScapiAuthHeaders({ proxyRequest, incomingRequest, caching: false, @@ -129,17 +129,17 @@ describe('applyProxyRequestAuthHeader', () => { it('applies Bearer token for SLAS logout endpoint', () => { utils.isScapiDomain.mockReturnValue(true) - cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'}) const proxyRequest = { setHeader: jest.fn() } const incomingRequest = { url: '/shopper/auth/v1/oauth2/logout', - headers: {cookie: 'access_token_RefArch=test-access-token'} + headers: {cookie: 'cc-at_RefArch=test-access-token'} } - applyProxyRequestAuthHeader({ + applyScapiAuthHeaders({ proxyRequest, incomingRequest, caching: false, @@ -156,17 +156,17 @@ describe('applyProxyRequestAuthHeader', () => { it('does not apply Bearer token for SLAS token endpoint', () => { utils.isScapiDomain.mockReturnValue(true) - cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'}) const proxyRequest = { setHeader: jest.fn() } const incomingRequest = { url: '/shopper/auth/v1/oauth2/token', - headers: {cookie: 'access_token_RefArch=test-access-token'} + headers: {cookie: 'cc-at_RefArch=test-access-token'} } - applyProxyRequestAuthHeader({ + applyScapiAuthHeaders({ proxyRequest, incomingRequest, caching: false, @@ -181,17 +181,17 @@ describe('applyProxyRequestAuthHeader', () => { it('does not apply Bearer token when caching is true', () => { utils.isScapiDomain.mockReturnValue(true) - cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'}) const proxyRequest = { setHeader: jest.fn() } const incomingRequest = { url: '/shopper/products/v1/products', - headers: {cookie: 'access_token_RefArch=test-access-token'} + headers: {cookie: 'cc-at_RefArch=test-access-token'} } - applyProxyRequestAuthHeader({ + applyScapiAuthHeaders({ proxyRequest, incomingRequest, caching: true, @@ -216,7 +216,7 @@ describe('applyProxyRequestAuthHeader', () => { headers: {} } - applyProxyRequestAuthHeader({ + applyScapiAuthHeaders({ proxyRequest, incomingRequest, caching: false, @@ -230,17 +230,17 @@ describe('applyProxyRequestAuthHeader', () => { it('does not apply Bearer token when target is not SCAPI domain', () => { utils.isScapiDomain.mockReturnValue(false) - cookie.parse.mockReturnValue({access_token_RefArch: 'test-access-token'}) + cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'}) const proxyRequest = { setHeader: jest.fn() } const incomingRequest = { url: '/api/products', - headers: {cookie: 'access_token_RefArch=test-access-token'} + headers: {cookie: 'cc-at_RefArch=test-access-token'} } - applyProxyRequestAuthHeader({ + applyScapiAuthHeaders({ proxyRequest, incomingRequest, caching: false, @@ -264,7 +264,7 @@ describe('applyProxyRequestAuthHeader', () => { headers: {} } - applyProxyRequestAuthHeader({ + applyScapiAuthHeaders({ proxyRequest, incomingRequest, caching: false, diff --git a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js index ae0e9f1bc6..b40e405606 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js @@ -49,7 +49,7 @@ const generalProxyPathRE = /^\/mobify\/proxy\/([^/]+)(\/.*)$/ * @param targetHost {String} the target hostname (host+port) * @param slasEndpointsRequiringAccessToken {RegExp} regex for SLAS auth endpoints that need Bearer token */ -export const applyProxyRequestAuthHeader = ({ +export const applyScapiAuthHeaders = ({ proxyRequest, incomingRequest, caching, @@ -79,7 +79,7 @@ export const applyProxyRequestAuthHeader = ({ if (!cookieHeader) return const cookies = cookie.parse(cookieHeader) - const tokenKey = `access_token_${siteId.trim()}` + const tokenKey = `cc-at_${siteId.trim()}` const accessToken = cookies[tokenKey] if (accessToken) { @@ -273,7 +273,7 @@ export const configureProxy = ({ }) // Apply Authorization header with shopper's access token from HttpOnly cookie - applyProxyRequestAuthHeader({ + applyScapiAuthHeaders({ proxyRequest, incomingRequest, caching, From a22fa1c7780d21c317e2e87d5175570b5e3eb531 Mon Sep 17 00:00:00 2001 From: unandyala Date: Sun, 22 Feb 2026 23:31:14 -0600 Subject: [PATCH 12/15] refactor --- .../commerce-sdk-react/src/auth/index.test.ts | 26 ++- packages/commerce-sdk-react/src/auth/index.ts | 20 +- .../ssr/server/build-remote-server.test.js | 4 +- .../src/ssr/server/process-token-response.js | 209 ++++++++---------- .../ssr/server/process-token-response.test.js | 37 ++-- 5 files changed, 130 insertions(+), 166 deletions(-) diff --git a/packages/commerce-sdk-react/src/auth/index.test.ts b/packages/commerce-sdk-react/src/auth/index.test.ts index 906b7071db..549863eb5e 100644 --- a/packages/commerce-sdk-react/src/auth/index.test.ts +++ b/packages/commerce-sdk-react/src/auth/index.test.ts @@ -1507,25 +1507,24 @@ describe('HttpOnly Session Cookies', () => { const expiresAtFuture = Math.floor(Date.now() / 1000) + 3600 const httpOnlyTokenResponse: ShopperLoginTypes.TokenResponse = { - ...TOKEN_RESPONSE, - // When HttpOnly cookies are enabled, the proxy strips tokens from the body - // and adds access_token_expires_at for client-side expiry checks - access_token_expires_at: expiresAtFuture + ...TOKEN_RESPONSE } beforeEach(() => { jest.clearAllMocks() }) - test('loginGuestUser stores access_token_expires_at but not tokens', async () => { + test('loginGuestUser does not store tokens when HttpOnly cookies are enabled', async () => { const auth = new Auth({...config, useHttpOnlySessionCookies: true}) const loginGuestMock = helpers.loginGuestUser as jest.Mock loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse) + // Set cc-at-expires cookie (as server would via Set-Cookie header) + // @ts-expect-error private method + auth.set('cc-at-expires', String(expiresAtFuture)) + await auth.loginGuestUser() - // access_token_expires_at should be stored for client-side expiry checks - expect(auth.get('access_token_expires_at')).toBe(String(expiresAtFuture)) // Tokens should NOT be stored in localStorage (they're in HttpOnly cookies) expect(auth.get('access_token')).toBeFalsy() expect(auth.get('refresh_token_guest')).toBeFalsy() @@ -1534,27 +1533,32 @@ describe('HttpOnly Session Cookies', () => { expect(auth.get('usid')).toBe(TOKEN_RESPONSE.usid) }) - test('ready re-uses data when access_token_expires_at is still valid', async () => { + test('ready re-uses data when cc-at-expires cookie is still valid', async () => { const auth = new Auth({...config, useHttpOnlySessionCookies: true}) const loginGuestMock = helpers.loginGuestUser as jest.Mock loginGuestMock.mockResolvedValueOnce(httpOnlyTokenResponse) // First call: triggers loginGuestUser await auth.ready() + + // Set cc-at-expires cookie (as server would via Set-Cookie header) + // @ts-expect-error private method + auth.set('cc-at-expires', String(expiresAtFuture)) + expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) - // Second call: access_token_expires_at is in the future, so it should re-use data + // Second call: cc-at-expires is in the future, so it should re-use data await auth.ready() expect(helpers.loginGuestUser).toHaveBeenCalledTimes(1) // Not called again }) - test('ready triggers refresh when access_token_expires_at is expired', async () => { + test('ready triggers refresh when cc-at-expires cookie is expired', async () => { const auth = new Auth({...config, useHttpOnlySessionCookies: true}) // Simulate a previous login that left behind stored data with an expired token const expiredTime = Math.floor(Date.now() / 1000) - 100 // @ts-expect-error private method - auth.set('access_token_expires_at', String(expiredTime)) + auth.set('cc-at-expires', String(expiredTime)) // @ts-expect-error private method auth.set('refresh_token_guest', 'refresh_token') // @ts-expect-error private method diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index fcb8f3a3c2..a3c328c2c4 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -138,7 +138,7 @@ type AuthDataKeys = | 'uido' | 'idp_refresh_token' | 'dnt' - | 'access_token_expires_at' + | 'cc-at-expires' type AuthDataMap = Record< AuthDataKeys, @@ -254,9 +254,9 @@ const DATA_MAP: AuthDataMap = { storageType: 'local', key: 'uido' }, - access_token_expires_at: { - storageType: 'local', - key: 'access_token_expires_at' + 'cc-at-expires': { + storageType: 'cookie', + key: 'cc-at-expires' } } @@ -528,11 +528,11 @@ class Auth { /** * Returns whether the access token is expired. When useHttpOnlySessionCookies is true, - * uses access_token_expires_at from store; otherwise decodes the JWT from getAccessToken(). + * uses cc-at-expires cookie from store; otherwise decodes the JWT from getAccessToken(). */ private isAccessTokenExpired(): boolean { if (this.useHttpOnlySessionCookies) { - const expiresAt = this.get('access_token_expires_at') + const expiresAt = this.get('cc-at-expires') if (expiresAt == null || expiresAt === '') return true const expiresAtSec = Number(expiresAt) if (Number.isNaN(expiresAtSec)) return true @@ -735,14 +735,6 @@ class Auth { const refreshTokenKey = isGuest ? 'refresh_token_guest' : 'refresh_token_registered' this.set(refreshTokenKey, res.refresh_token, {expires: expiresDate}) } - if ( - res && - typeof res === 'object' && - 'access_token_expires_at' in res && - res.access_token_expires_at != null - ) { - this.set('access_token_expires_at', String(res.access_token_expires_at)) - } } async refreshAccessToken() { diff --git a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js index c52a6fe33f..e23a150c81 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js +++ b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js @@ -582,7 +582,7 @@ describe('HttpOnly session cookies', () => { expect(response.headers['set-cookie']).toBeDefined() const cookies = response.headers['set-cookie'] expect(cookies.some((c) => c.includes('cc-at_testsite'))).toBe(true) - expect(cookies.some((c) => c.includes('cc-at-expires-at_testsite'))).toBe(true) + expect(cookies.some((c) => c.includes('cc-at-expires_testsite'))).toBe(true) expect(cookies.some((c) => c.includes('cc-nx-g_testsite'))).toBe(true) } finally { mockSlasServerInstance.close() @@ -692,7 +692,7 @@ describe('HttpOnly session cookies', () => { expect(response.headers['set-cookie']).toBeDefined() const cookies = response.headers['set-cookie'] expect(cookies.some((c) => c.includes('cc-at_testsite'))).toBe(true) - expect(cookies.some((c) => c.includes('cc-at-expires-at_testsite'))).toBe(true) + expect(cookies.some((c) => c.includes('cc-at-expires_testsite'))).toBe(true) } finally { mockSlasServerInstance.close() } diff --git a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js index 31412d0a2f..e14a0c8953 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js +++ b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js @@ -35,14 +35,11 @@ export function getRefreshTokenCookieTTL(refreshTokenExpiresInSLASValue, isGuest } /** - * Decodes the SLAS access token JWT, extracts claims, and sets non-HttpOnly metadata cookies - * (expires-at, dnt, uido) so the client can read them. Same field extraction as + * Decodes the SLAS access token JWT and extracts claims. Same field extraction as * commerce-sdk-react parseSlasJWT. - * - * Returns {isGuest} for the caller to determine the refresh token cookie name. * @private */ -function setTokenClaimCookies(res, siteId, accessToken, expiresInSeconds) { +function getTokenClaims(accessToken) { let payload try { payload = jwtDecode(accessToken) @@ -50,41 +47,7 @@ function setTokenClaimCookies(res, siteId, accessToken, expiresInSeconds) { throw new Error(`Failed to decode access token JWT: ${error.message || error}. `) } - const accessExpires = new Date(Date.now() + expiresInSeconds * 1000) - - // Expiry timestamp — use JWT iat when available (non-HttpOnly so client can check expiry) - let expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds - if (typeof payload.iat === 'number') { - expiresAt = payload.iat + expiresInSeconds - } - res.append( - SET_COOKIE, - cookieAsString({ - name: `cc-at-expires-at_${siteId}`, - value: String(expiresAt), - path: '/', - secure: true, - sameSite: 'lax', - httpOnly: false, - expires: accessExpires - }) - ) - - // Do-not-track flag from JWT (non-HttpOnly so client can read it) - if (payload.dnt !== undefined) { - res.append( - SET_COOKIE, - cookieAsString({ - name: `cc-at-dnt_${siteId}`, - value: String(payload.dnt), - path: '/', - secure: true, - sameSite: 'lax', - httpOnly: false, - expires: accessExpires - }) - ) - } + const accessExpires = new Date(payload.exp * 1000) // Extract isGuest and uido from JWT isb claim let isGuest = true @@ -96,66 +59,7 @@ function setTokenClaimCookies(res, siteId, accessToken, expiresInSeconds) { if (uidoPart) uido = uidoPart } - // uido: IDP origin (e.g. "slas", "ecom"); non-HttpOnly so client can read for useCustomerType/isExternal - if (uido) { - res.append( - SET_COOKIE, - cookieAsString({ - name: `uido_${siteId}`, - value: uido, - path: '/', - secure: true, - sameSite: 'lax', - httpOnly: false, - expires: accessExpires - }) - ) - } - - return {isGuest} -} - -/** - * Sets the IDP access token as an HttpOnly cookie. - * @private - */ -function setIdpAccessTokenCookie(res, siteId, idpAccessToken, expiresInSeconds) { - const idpExpires = new Date(Date.now() + expiresInSeconds * 1000) - res.append( - SET_COOKIE, - cookieAsString({ - name: `idp_access_token_${siteId}`, - value: idpAccessToken, - path: '/', - secure: true, - sameSite: 'lax', - httpOnly: true, - expires: idpExpires - }) - ) -} - -/** - * Sets the refresh token as an HttpOnly cookie. Cookie name depends on guest vs registered user. - * @private - */ -function setRefreshTokenCookie(res, siteId, refreshToken, refreshTokenExpiresIn, isGuest) { - const refreshTTL = getRefreshTokenCookieTTL(refreshTokenExpiresIn, isGuest) - const refreshExpires = new Date(Date.now() + refreshTTL * 1000) - const refreshCookieName = isGuest ? `cc-nx-g_${siteId}` : `cc-nx_${siteId}` - - res.append( - SET_COOKIE, - cookieAsString({ - name: refreshCookieName, - value: refreshToken, - path: '/', - secure: true, - sameSite: 'lax', - httpOnly: true, - expires: refreshExpires - }) - ) + return {accessExpires, expiresAt: payload.exp, dnt: payload.dnt, isGuest, uido} } /** @@ -180,13 +84,20 @@ export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, } const site = siteId.trim() - const expiresInSeconds = typeof parsed.expires_in === 'number' ? parsed.expires_in : 1800 - // Decode JWT, set metadata cookies (expires-at, dnt, uido), get isGuest + // Decode JWT and extract claims let isGuest = true if (parsed.access_token) { + const { + accessExpires, + expiresAt, + dnt, + uido, + isGuest: guest + } = getTokenClaims(parsed.access_token) + isGuest = guest + // Access token (HttpOnly) - const accessExpires = new Date(Date.now() + expiresInSeconds * 1000) res.append( SET_COOKIE, cookieAsString({ @@ -200,23 +111,91 @@ export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, }) ) - const claims = setTokenClaimCookies(res, site, parsed.access_token, expiresInSeconds) - isGuest = claims.isGuest - } + // Expiry timestamp from JWT exp claim (non-HttpOnly so client can check expiry) + res.append( + SET_COOKIE, + cookieAsString({ + name: `cc-at-expires_${site}`, + value: String(expiresAt), + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: false, + expires: accessExpires + }) + ) - // IDP access token - if (parsed.idp_access_token) { - setIdpAccessTokenCookie(res, site, parsed.idp_access_token, expiresInSeconds) + // Do-not-track flag from JWT (non-HttpOnly so client can read it) + if (dnt !== undefined) { + res.append( + SET_COOKIE, + cookieAsString({ + name: `cc-at-dnt_${site}`, + value: String(dnt), + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: false, + expires: accessExpires + }) + ) + } + + // uido: IDP origin (e.g. "slas", "ecom"); non-HttpOnly so client can read for useCustomerType/isExternal + if (uido) { + res.append( + SET_COOKIE, + cookieAsString({ + name: `uido_${site}`, + value: uido, + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: false, + expires: accessExpires + }) + ) + } + + // IDP access token (HttpOnly) + if (parsed.idp_access_token) { + res.append( + SET_COOKIE, + cookieAsString({ + name: `idp_access_token_${site}`, + value: parsed.idp_access_token, + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: true, + expires: accessExpires + }) + ) + } } - // Refresh token + // Refresh token (HttpOnly) — uses its own TTL, independent of access token expiry if (parsed.refresh_token) { - setRefreshTokenCookie( - res, - site, - parsed.refresh_token, + const commerceAPI = options.mobify?.app?.commerceAPI || {} + const refreshTTL = getRefreshTokenCookieTTL( parsed.refresh_token_expires_in, - isGuest + isGuest, + commerceAPI + ) + const refreshExpires = new Date(Date.now() + refreshTTL * 1000) + const refreshCookieName = isGuest ? `cc-nx-g_${site}` : `cc-nx_${site}` + + res.append( + SET_COOKIE, + cookieAsString({ + name: refreshCookieName, + value: parsed.refresh_token, + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: true, + expires: refreshExpires + }) ) } diff --git a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js index e4d0c8f07e..a23f8ff06f 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js +++ b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js @@ -155,6 +155,7 @@ describe('applyHttpOnlySessionCookies', () => { const res = makeRes() const accessToken = makeJWT({ iat: 1000, + exp: 2800, isb: 'uido:ecom::upn:Guest::uidn:Guest::gcid:g1', dnt: '1' }) @@ -174,11 +175,11 @@ describe('applyHttpOnlySessionCookies', () => { expect(atCookie.secure).toBe(true) expect(atCookie.path).toBe('/') - // cc-at-expires-at: expiry from iat (non-HttpOnly) + // cc-at-expires: expiry from JWT exp claim (non-HttpOnly) const expCookie = parseCookie( - res.cookies.find((c) => c.includes('cc-at-expires-at_testsite=')) + res.cookies.find((c) => c.includes('cc-at-expires_testsite=')) ) - expect(expCookie.value).toBe(String(1000 + 1800)) + expect(expCookie.value).toBe(String(2800)) expect(expCookie.httpOnly).toBeUndefined() // cc-at-dnt: do-not-track from JWT (non-HttpOnly) @@ -223,6 +224,7 @@ describe('applyHttpOnlySessionCookies', () => { const res = makeRes() const accessToken = makeJWT({ iat: 2000, + exp: 3800, isb: 'uido:ecom::upn:john@example.com::uidn:John' }) const buf = makeResponseBuffer({ @@ -236,11 +238,11 @@ describe('applyHttpOnlySessionCookies', () => { const atCookie = parseCookie(res.cookies.find((c) => c.includes('cc-at_testsite='))) expect(atCookie.httpOnly).toBe(true) - // cc-at-expires-at (non-HttpOnly) + // cc-at-expires (non-HttpOnly) const expCookie = parseCookie( - res.cookies.find((c) => c.includes('cc-at-expires-at_testsite=')) + res.cookies.find((c) => c.includes('cc-at-expires_testsite=')) ) - expect(expCookie.value).toBe(String(2000 + 1800)) + expect(expCookie.value).toBe(String(3800)) // cc-nx: registered refresh token (HttpOnly) const refreshCookie = parseCookie( @@ -267,7 +269,7 @@ describe('applyHttpOnlySessionCookies', () => { test('omits uido cookie when uido is absent from JWT', () => { const res = makeRes() - const accessToken = makeJWT({iat: 1000, isb: '::upn:Guest'}) + const accessToken = makeJWT({iat: 1000, exp: 2800, isb: '::upn:Guest'}) const buf = makeResponseBuffer({ access_token: accessToken, refresh_token: 'refresh-value', @@ -278,19 +280,6 @@ describe('applyHttpOnlySessionCookies', () => { expect(res.cookies.find((c) => c.includes('uido_testsite'))).toBeUndefined() }) - test('uses Date.now fallback for cc-at-expires-at when iat is missing', () => { - const res = makeRes() - const now = Math.floor(Date.now() / 1000) - const accessToken = makeJWT({isb: 'uido:ecom::upn:Guest'}) - const buf = makeResponseBuffer({access_token: accessToken, expires_in: 900}) - applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) - - const expCookie = res.cookies.find((c) => c.includes('cc-at-expires-at_testsite=')) - const expiresAt = parseInt(parseCookie(expCookie).value, 10) - expect(expiresAt).toBeGreaterThanOrEqual(now + 900 - 5) - expect(expiresAt).toBeLessThanOrEqual(now + 900 + 5) - }) - test('throws when access token JWT is invalid', () => { const res = makeRes() const buf = makeResponseBuffer({access_token: 'not-a-jwt', expires_in: 1800}) @@ -299,15 +288,15 @@ describe('applyHttpOnlySessionCookies', () => { ) }) - test('defaults expires_in to 1800 when not a number', () => { + test('uses JWT exp for cookie expiry regardless of expires_in', () => { const res = makeRes() - const accessToken = makeJWT({iat: 5000, isb: 'uido:ecom::upn:Guest'}) + const accessToken = makeJWT({iat: 5000, exp: 6800, isb: 'uido:ecom::upn:Guest'}) const buf = makeResponseBuffer({access_token: accessToken}) applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) - const expCookie = res.cookies.find((c) => c.includes('cc-at-expires-at_testsite=')) + const expCookie = res.cookies.find((c) => c.includes('cc-at-expires_testsite=')) const parsed = parseCookie(expCookie) - expect(parsed.value).toBe(String(5000 + 1800)) + expect(parsed.value).toBe(String(6800)) }) test('handles response with no tokens (no cookies set, body returned stripped)', () => { From a088f32384b85249127b28045420090e41e32496 Mon Sep 17 00:00:00 2001 From: unandyala Date: Mon, 23 Feb 2026 08:29:49 -0600 Subject: [PATCH 13/15] fix lint errors --- .../ssr/server/process-token-response.test.js | 18 ++++++------------ .../ssr-server/configure-proxy.basic.test.js | 6 +----- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js index a23f8ff06f..0981bcd187 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js +++ b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js @@ -85,9 +85,9 @@ describe('getRefreshTokenCookieTTL', () => { test('uses valid registered override', () => { const ttl = 1000 - expect( - getRefreshTokenCookieTTL(12345, false, {refreshTokenRegisteredCookieTTL: ttl}) - ).toBe(ttl) + expect(getRefreshTokenCookieTTL(12345, false, {refreshTokenRegisteredCookieTTL: ttl})).toBe( + ttl + ) }) test('rejects override exceeding default and warns', () => { @@ -183,9 +183,7 @@ describe('applyHttpOnlySessionCookies', () => { expect(expCookie.httpOnly).toBeUndefined() // cc-at-dnt: do-not-track from JWT (non-HttpOnly) - const dntCookie = parseCookie( - res.cookies.find((c) => c.includes('cc-at-dnt_testsite=')) - ) + const dntCookie = parseCookie(res.cookies.find((c) => c.includes('cc-at-dnt_testsite='))) expect(dntCookie.value).toBe('1') expect(dntCookie.httpOnly).toBeUndefined() @@ -197,9 +195,7 @@ describe('applyHttpOnlySessionCookies', () => { expect(idpCookie.httpOnly).toBe(true) // cc-nx-g: guest refresh token (HttpOnly) - const refreshCookie = parseCookie( - res.cookies.find((c) => c.includes('cc-nx-g_testsite=')) - ) + const refreshCookie = parseCookie(res.cookies.find((c) => c.includes('cc-nx-g_testsite='))) expect(refreshCookie.value).toBe('refresh-value') expect(refreshCookie.httpOnly).toBe(true) @@ -245,9 +241,7 @@ describe('applyHttpOnlySessionCookies', () => { expect(expCookie.value).toBe(String(3800)) // cc-nx: registered refresh token (HttpOnly) - const refreshCookie = parseCookie( - res.cookies.find((c) => c.includes('cc-nx_testsite=')) - ) + const refreshCookie = parseCookie(res.cookies.find((c) => c.includes('cc-nx_testsite='))) expect(refreshCookie.value).toBe('refresh-value') expect(refreshCookie.httpOnly).toBe(true) diff --git a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js index ba660ccf52..406eb63b2e 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js @@ -4,11 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { - applyProxyRequestHeaders, - applyScapiAuthHeaders, - configureProxy -} from './configure-proxy' +import {applyProxyRequestHeaders, applyScapiAuthHeaders, configureProxy} from './configure-proxy' import * as ssrProxying from '../ssr-proxying' import * as utils from './utils' import cookie from 'cookie' From dbd091826ff44d30eb6ea78db9eac59feabf8d21 Mon Sep 17 00:00:00 2001 From: unandyala Date: Mon, 23 Feb 2026 11:32:47 -0600 Subject: [PATCH 14/15] minor fix --- packages/commerce-sdk-react/src/provider.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/commerce-sdk-react/src/provider.tsx b/packages/commerce-sdk-react/src/provider.tsx index f18c07fb19..5a32c6bda2 100644 --- a/packages/commerce-sdk-react/src/provider.tsx +++ b/packages/commerce-sdk-react/src/provider.tsx @@ -155,11 +155,12 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => { const configLogger = logger || console // When HttpOnly cookies are enabled, ensure fetch credentials allow cookies to be sent. - const effectiveFetchOptions = - useHttpOnlySessionCookies && - (!fetchOptions?.credentials || fetchOptions.credentials === 'omit') + const effectiveFetchOptions = useMemo(() => { + return useHttpOnlySessionCookies && + (!fetchOptions?.credentials || fetchOptions.credentials === 'omit') ? {...fetchOptions, credentials: 'same-origin' as RequestCredentials} : fetchOptions + }, [useHttpOnlySessionCookies, fetchOptions]) const auth = useMemo(() => { return new Auth({ From 60906055e18463a58d1dddd6b27b420d785da9de Mon Sep 17 00:00:00 2001 From: unandyala Date: Mon, 23 Feb 2026 13:44:33 -0600 Subject: [PATCH 15/15] minor fix --- .../pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js index b40e405606..38662e64bd 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js @@ -12,6 +12,7 @@ import {processExpressResponse} from './process-express-response' import {isRemote, localDevLog, verboseProxyLogging, isScapiDomain} from './utils' import logger from '../logger-instance' import {getEnvBasePath} from '../ssr-namespace-paths' +import {SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN} from '../../ssr/server/constants' export const ALLOWED_CACHING_PROXY_REQUEST_METHODS = ['HEAD', 'GET', 'OPTIONS'] @@ -195,7 +196,7 @@ export const configureProxy = ({ appProtocol = /* istanbul ignore next */ 'https', caching, siteId = null, - slasEndpointsRequiringAccessToken = /\/oauth2\/logout/ + slasEndpointsRequiringAccessToken = SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN }) => { // This configuration must match the behaviour of the proxying // in CloudFront. @@ -380,7 +381,7 @@ export const configureProxyConfigs = ( appHostname, appProtocol, siteId = null, - slasEndpointsRequiringAccessToken = /\/oauth2\/logout/ + slasEndpointsRequiringAccessToken = SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN ) => { localDevLog('') proxyConfigs.forEach((config) => {