diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md
index 184ca75c6a..b37fe11704 100644
--- a/packages/commerce-sdk-react/CHANGELOG.md
+++ b/packages/commerce-sdk-react/CHANGELOG.md
@@ -1,3 +1,6 @@
+## [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)
diff --git a/packages/commerce-sdk-react/src/auth/index.test.ts b/packages/commerce-sdk-react/src/auth/index.test.ts
index d8331ea5a2..549863eb5e 100644
--- a/packages/commerce-sdk-react/src/auth/index.test.ts
+++ b/packages/commerce-sdk-react/src/auth/index.test.ts
@@ -1502,3 +1502,72 @@ 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
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ 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()
+
+ // 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 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: 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 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('cc-at-expires', 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..a3c328c2c4 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, session tokens are set as HttpOnly cookies */
+ useHttpOnlySessionCookies?: boolean
}
interface JWTHeaders {
@@ -136,6 +138,7 @@ type AuthDataKeys =
| 'uido'
| 'idp_refresh_token'
| 'dnt'
+ | 'cc-at-expires'
type AuthDataMap = Record<
AuthDataKeys,
@@ -250,6 +253,10 @@ const DATA_MAP: AuthDataMap = {
uido: {
storageType: 'local',
key: 'uido'
+ },
+ 'cc-at-expires': {
+ storageType: 'cookie',
+ key: 'cc-at-expires'
}
}
@@ -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 cc-at-expires cookie from store; otherwise decodes the JWT from getAccessToken().
+ */
+ private isAccessTokenExpired(): boolean {
+ if (this.useHttpOnlySessionCookies) {
+ const expiresAt = this.get('cc-at-expires')
+ 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,35 @@ 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})
+ }
}
async refreshAccessToken() {
@@ -747,7 +775,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 +916,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..5a32c6bda2 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,21 @@ 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.
+ 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({
clientId,
@@ -160,7 +171,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
proxy,
redirectURI,
headers,
- fetchOptions,
+ fetchOptions: effectiveFetchOptions,
fetchedToken,
enablePWAKitPrivateClient,
privateClientProxyEndpoint,
@@ -171,7 +182,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
passwordlessLoginCallbackURI,
refreshTokenRegisteredCookieTTL,
refreshTokenGuestCookieTTL,
- hybridAuthEnabled
+ hybridAuthEnabled,
+ useHttpOnlySessionCookies
})
}, [
clientId,
@@ -181,7 +193,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
proxy,
redirectURI,
headers,
- fetchOptions,
+ effectiveFetchOptions,
fetchedToken,
enablePWAKitPrivateClient,
privateClientProxyEndpoint,
@@ -193,7 +205,8 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
refreshTokenRegisteredCookieTTL,
refreshTokenGuestCookieTTL,
apiClients,
- hybridAuthEnabled
+ hybridAuthEnabled,
+ useHttpOnlySessionCookies
])
const dwsid = auth.get(DWSID_COOKIE_NAME)
@@ -212,7 +225,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
throwOnBadResponse: true,
fetchOptions: {
...options.fetchOptions,
- ...fetchOptions
+ ...effectiveFetchOptions
}
}
}
@@ -252,7 +265,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
currency
},
throwOnBadResponse: true,
- fetchOptions
+ fetchOptions: effectiveFetchOptions
}
return {
@@ -279,7 +292,7 @@ const CommerceApiProvider = (props: CommerceApiProviderProps): ReactElement => {
shortCode,
siteId,
proxy,
- fetchOptions,
+ effectiveFetchOptions,
locale,
currency,
headers?.['correlation-id'],
diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md
index 1eb5e8a0e1..fd4f0bae3c 100644
--- a/packages/pwa-kit-create-app/CHANGELOG.md
+++ b/packages/pwa-kit-create-app/CHANGELOG.md
@@ -1,3 +1,6 @@
+## [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)
- 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-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/pwa-kit-dev/CHANGELOG.md b/packages/pwa-kit-dev/CHANGELOG.md
index 1646b9366a..d6c391d0a6 100644
--- a/packages/pwa-kit-dev/CHANGELOG.md
+++ b/packages/pwa-kit-dev/CHANGELOG.md
@@ -1,6 +1,9 @@
+## [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)
-- Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635)
## v3.16.0 (Feb 12, 2026)
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..5a8aa5f6be 100644
--- a/packages/pwa-kit-runtime/CHANGELOG.md
+++ b/packages/pwa-kit-runtime/CHANGELOG.md
@@ -1,3 +1,6 @@
+## [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)
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..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,
@@ -60,6 +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 {getConfig} from '../../utils/ssr-config'
+import {applyHttpOnlySessionCookies} from './process-token-response'
/**
* An Array of mime-types (Content-Type values) that are considered
@@ -160,6 +164,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: 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: 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
// request modifications (e.g., custom headers).
@@ -167,8 +182,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 +229,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 +398,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 +1001,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.
@@ -983,7 +1062,7 @@ export const RemoteServerFactory = {
if (typeof options.onSLASPrivateProxyRes === 'function') {
try {
const customBuffer = options.onSLASPrivateProxyRes(
- responseBuffer,
+ workingBuffer,
proxyRes,
req,
res
@@ -1387,6 +1466,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..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
@@ -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 () => {
@@ -359,6 +367,338 @@ describe('SLAS private proxy', () => {
})
})
+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.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_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('cc-at_testsite'))).toBe(true)
+ expect(cookies.some((c) => c.includes('cc-at-expires_testsite'))).toBe(true)
+ } finally {
+ mockSlasServerInstance.close()
+ }
+ })
+})
+
describe('errorHandlerMiddleware logic', () => {
it('calls sendMetric and sendStatus(500) when error is handled', () => {
catchAndLog.mockImplementation(() => {})
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..e14a0c8953
--- /dev/null
+++ b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js
@@ -0,0 +1,208 @@
+/*
+ * 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 and extracts claims. Same field extraction as
+ * commerce-sdk-react parseSlasJWT.
+ * @private
+ */
+function getTokenClaims(accessToken) {
+ let payload
+ try {
+ payload = jwtDecode(accessToken)
+ } catch (error) {
+ throw new Error(`Failed to decode access token JWT: ${error.message || error}. `)
+ }
+
+ const accessExpires = new Date(payload.exp * 1000)
+
+ // 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
+ }
+
+ return {accessExpires, expiresAt: payload.exp, dnt: payload.dnt, isGuest, uido}
+}
+
+/**
+ * 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()
+
+ // 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)
+ res.append(
+ SET_COOKIE,
+ cookieAsString({
+ name: `cc-at_${site}`,
+ value: parsed.access_token,
+ path: '/',
+ secure: true,
+ sameSite: 'lax',
+ httpOnly: true,
+ expires: accessExpires
+ })
+ )
+
+ // 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
+ })
+ )
+
+ // 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 (HttpOnly) — uses its own TTL, independent of access token expiry
+ if (parsed.refresh_token) {
+ const commerceAPI = options.mobify?.app?.commerceAPI || {}
+ const refreshTTL = getRefreshTokenCookieTTL(
+ parsed.refresh_token_expires_in,
+ 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
+ })
+ )
+ }
+
+ // 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..0981bcd187
--- /dev/null
+++ b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js
@@ -0,0 +1,317 @@
+/*
+ * 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,
+ exp: 2800,
+ 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: expiry from JWT exp claim (non-HttpOnly)
+ const expCookie = parseCookie(
+ res.cookies.find((c) => c.includes('cc-at-expires_testsite='))
+ )
+ expect(expCookie.value).toBe(String(2800))
+ 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,
+ exp: 3800,
+ 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 (non-HttpOnly)
+ const expCookie = parseCookie(
+ res.cookies.find((c) => c.includes('cc-at-expires_testsite='))
+ )
+ expect(expCookie.value).toBe(String(3800))
+
+ // 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, exp: 2800, 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('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('uses JWT exp for cookie expiry regardless of expires_in', () => {
+ const res = makeRes()
+ 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_testsite='))
+ const parsed = parseCookie(expCookie)
+ expect(parsed.value).toBe(String(6800))
+ })
+
+ 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()
+ })
+})
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..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,12 +4,16 @@
* 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,
- ALLOWED_CACHING_PROXY_REQUEST_METHODS,
- 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'
+
+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 +89,186 @@ describe('configureProxy ALLOWED_CACHING_PROXY_REQUEST_METHODS', () => {
expect(typeof result).toBe('function')
})
})
+
+describe('applyScapiAuthHeaders', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('applies Bearer token for non-SLAS Shopper API endpoints', () => {
+ utils.isScapiDomain.mockReturnValue(true)
+ cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'})
+
+ const proxyRequest = {
+ setHeader: jest.fn(),
+ removeHeader: jest.fn()
+ }
+ const incomingRequest = {
+ url: '/shopper/products/v1/products',
+ headers: {cookie: 'cc-at_RefArch=test-access-token'}
+ }
+
+ applyScapiAuthHeaders({
+ 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({'cc-at_RefArch': 'test-access-token'})
+
+ const proxyRequest = {
+ setHeader: jest.fn()
+ }
+ const incomingRequest = {
+ url: '/shopper/auth/v1/oauth2/logout',
+ headers: {cookie: 'cc-at_RefArch=test-access-token'}
+ }
+
+ applyScapiAuthHeaders({
+ 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({'cc-at_RefArch': 'test-access-token'})
+
+ const proxyRequest = {
+ setHeader: jest.fn()
+ }
+ const incomingRequest = {
+ url: '/shopper/auth/v1/oauth2/token',
+ headers: {cookie: 'cc-at_RefArch=test-access-token'}
+ }
+
+ applyScapiAuthHeaders({
+ 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({'cc-at_RefArch': 'test-access-token'})
+
+ const proxyRequest = {
+ setHeader: jest.fn()
+ }
+ const incomingRequest = {
+ url: '/shopper/products/v1/products',
+ headers: {cookie: 'cc-at_RefArch=test-access-token'}
+ }
+
+ applyScapiAuthHeaders({
+ 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: {}
+ }
+
+ applyScapiAuthHeaders({
+ 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({'cc-at_RefArch': 'test-access-token'})
+
+ const proxyRequest = {
+ setHeader: jest.fn()
+ }
+ const incomingRequest = {
+ url: '/api/products',
+ headers: {cookie: 'cc-at_RefArch=test-access-token'}
+ }
+
+ applyScapiAuthHeaders({
+ 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: {}
+ }
+
+ applyScapiAuthHeaders({
+ 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..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
@@ -5,12 +5,14 @@
* 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'
+import {SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN} from '../../ssr/server/constants'
export const ALLOWED_CACHING_PROXY_REQUEST_METHODS = ['HEAD', 'GET', 'OPTIONS']
@@ -24,6 +26,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 applyScapiAuthHeaders = ({
+ 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 = `cc-at_${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 +184,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 +194,9 @@ export const configureProxy = ({
targetProtocol,
targetHost,
appProtocol = /* istanbul ignore next */ 'https',
- caching
+ caching,
+ siteId = null,
+ slasEndpointsRequiringAccessToken = SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN
}) => {
// This configuration must match the behaviour of the proxying
// in CloudFront.
@@ -194,6 +263,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 +272,16 @@ export const configureProxy = ({
targetHost,
targetProtocol
})
+
+ // Apply Authorization header with shopper's access token from HttpOnly cookie
+ applyScapiAuthHeaders({
+ proxyRequest,
+ incomingRequest,
+ caching,
+ siteId,
+ targetHost,
+ slasEndpointsRequiringAccessToken
+ })
},
onProxyRes: (proxyResponse, req) => {
@@ -293,9 +373,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 = SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN
+) => {
localDevLog('')
proxyConfigs.forEach((config) => {
localDevLog(
@@ -307,7 +394,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 +404,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/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md
index e725c125a5..0ea31cf853 100644
--- a/packages/template-retail-react-app/CHANGELOG.md
+++ b/packages/template-retail-react-app/CHANGELOG.md
@@ -1,3 +1,6 @@
+## [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)
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..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
@@ -111,6 +111,11 @@ const AppConfig = ({children, locals = {}}) => {
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/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',