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',