diff --git a/packages/pwa-kit-runtime/CHANGELOG.md b/packages/pwa-kit-runtime/CHANGELOG.md index 5a8aa5f6be..074f6e7851 100644 --- a/packages/pwa-kit-runtime/CHANGELOG.md +++ b/packages/pwa-kit-runtime/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] - Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680) +- Handle logout when HttpOnly session cookies is enabled [#3699](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3699) ## 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/src/ssr/server/build-remote-server.js b/packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js index 16b98fe65e..36c31cdea5 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 @@ -5,6 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import path from 'path' +import cookie from 'cookie' import { BUILD, CONTENT_TYPE, @@ -399,13 +400,7 @@ export const RemoteServerFactory = { */ _configureProxyConfigs(options) { const siteId = options.siteId || null - const slasEndpointsRequiringAccessToken = options.slasEndpointsRequiringAccessToken - configureProxyConfigs( - options.appHostname, - options.protocol, - siteId, - slasEndpointsRequiringAccessToken - ) + configureProxyConfigs(options.appHostname, options.protocol, siteId) }, /** @@ -982,6 +977,41 @@ export const RemoteServerFactory = { // SLAS logout (/oauth2/logout), use the Authorization header for a different // purpose so we don't want to overwrite the header for those calls. proxyRequest.setHeader('Authorization', `Basic ${encodedSlasCredentials}`) + } else if ( + process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES === 'false' && + incomingRequest.path?.match(options.slasEndpointsRequiringAccessToken) + ) { + // Inject tokens from HttpOnly cookies for endpoints like /oauth2/logout + const cookieHeader = incomingRequest.headers.cookie + if (cookieHeader) { + const cookies = cookie.parse(cookieHeader) + const siteId = options.mobify?.app?.commerceAPI?.parameters?.siteId + if (siteId) { + const site = siteId.trim() + + // Inject Bearer token from access token cookie + const accessToken = cookies[`cc-at_${site}`] + if (accessToken) { + proxyRequest.setHeader('Authorization', `Bearer ${accessToken}`) + } + + // Inject refresh_token into query string from HttpOnly cookie + // refresh_token ishouls required for /oauth2/logout + const refreshToken = cookies[`cc-nx_${site}`] + if (refreshToken) { + const url = new URL(proxyRequest.path, 'http://localhost') + url.searchParams.set('refresh_token', refreshToken) + proxyRequest.path = url.pathname + url.search + } else { + logger.warn( + `Registered refresh token cookie (cc-nx_${site}) not found for ${incomingRequest.path}. The logout request may fail.`, + { + namespace: '_setupSlasPrivateClientProxy' + } + ) + } + } + } } // Allow users to apply additional custom modifications to 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 e23a150c81..a6084a5dc6 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 @@ -483,11 +483,15 @@ describe('HttpOnly session cookies', () => { } }) - test('skips non-token endpoints like logout', async () => { + test('injects Bearer token and refresh token from HttpOnly cookies for logout endpoint', async () => { process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false' + let capturedAuthHeader + let capturedRefreshToken const mockSlasServer = mockExpress() mockSlasServer.post('/shopper/auth/v1/oauth2/logout', (req, res) => { + capturedAuthHeader = req.headers.authorization + capturedRefreshToken = req.query.refresh_token res.status(200).json({success: true}) }) @@ -517,12 +521,17 @@ describe('HttpOnly session cookies', () => { RemoteServerFactory._setupSlasPrivateClientProxy(app, options) - const response = await request(app).post( - '/mobify/slas/private/shopper/auth/v1/oauth2/logout' - ) + const response = await request(app) + .post('/mobify/slas/private/shopper/auth/v1/oauth2/logout') + .set( + 'Cookie', + 'cc-at_testsite=mock-access-token; cc-nx_testsite=mock-refresh-token' + ) expect(response.status).toBe(200) expect(response.body.success).toBe(true) + expect(capturedAuthHeader).toBe('Bearer mock-access-token') + expect(capturedRefreshToken).toBe('mock-refresh-token') expect(response.headers['set-cookie']).toBeUndefined() } finally { mockSlasServerInstance.close() diff --git a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js index e14a0c8953..6572e00788 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js +++ b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.js @@ -197,6 +197,23 @@ export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, expires: refreshExpires }) ) + + // Delete the opposite refresh token cookie to mirror client-side behavior: + // Login (guest → registered): delete guest cookie cc-nx-g + // Logout (registered → guest): delete registered cookie cc-nx + const staleCookieName = isGuest ? `cc-nx_${site}` : `cc-nx-g_${site}` + res.append( + SET_COOKIE, + cookieAsString({ + name: staleCookieName, + value: '', + path: '/', + secure: true, + sameSite: 'lax', + httpOnly: true, + expires: new Date(0) + }) + ) } // Strip token fields from body so they are not exposed to the client diff --git a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js index 0981bcd187..1d816f5401 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js +++ b/packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js @@ -204,8 +204,12 @@ describe('applyHttpOnlySessionCookies', () => { 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() + // Registered refresh cookie should be expired (deleted) + const staleRegisteredCookie = parseCookie( + res.cookies.find((c) => c.startsWith('cc-nx_testsite=')) + ) + expect(staleRegisteredCookie.value).toBe('') + expect(staleRegisteredCookie.expires).toEqual(new Date(0)) // Tokens stripped from body, other fields preserved const body = JSON.parse(result.toString('utf8')) @@ -249,8 +253,12 @@ describe('applyHttpOnlySessionCookies', () => { 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() + // Guest refresh cookie should be expired (deleted) + const staleGuestCookie = parseCookie( + res.cookies.find((c) => c.startsWith('cc-nx-g_testsite=')) + ) + expect(staleGuestCookie.value).toBe('') + expect(staleGuestCookie.expires).toEqual(new Date(0)) // No dnt cookie when dnt absent from JWT expect(res.cookies.find((c) => c.includes('cc-at-dnt_testsite'))).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 406eb63b2e..73491d14db 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 @@ -113,8 +113,7 @@ describe('applyScapiAuthHeaders', () => { incomingRequest, caching: false, siteId: 'RefArch', - targetHost: 'abc-001.api.commercecloud.salesforce.com', - slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + targetHost: 'abc-001.api.commercecloud.salesforce.com' }) expect(proxyRequest.setHeader).toHaveBeenCalledWith( @@ -123,7 +122,7 @@ describe('applyScapiAuthHeaders', () => { ) }) - it('applies Bearer token for SLAS logout endpoint', () => { + it('skips all SLAS auth endpoints (handled by SLAS private proxy)', () => { utils.isScapiDomain.mockReturnValue(true) cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'}) @@ -140,38 +139,10 @@ describe('applyScapiAuthHeaders', () => { incomingRequest, caching: false, siteId: 'RefArch', - targetHost: 'abc-001.api.commercecloud.salesforce.com', - slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + targetHost: 'abc-001.api.commercecloud.salesforce.com' }) - 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) + // SLAS auth endpoints are handled by the SLAS private client proxy expect(proxyRequest.setHeader).not.toHaveBeenCalled() }) @@ -192,8 +163,7 @@ describe('applyScapiAuthHeaders', () => { incomingRequest, caching: true, siteId: 'RefArch', - targetHost: 'abc-001.api.commercecloud.salesforce.com', - slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + targetHost: 'abc-001.api.commercecloud.salesforce.com' }) // Caching proxies don't use auth @@ -217,8 +187,7 @@ describe('applyScapiAuthHeaders', () => { incomingRequest, caching: false, siteId: null, - targetHost: 'abc-001.api.commercecloud.salesforce.com', - slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + targetHost: 'abc-001.api.commercecloud.salesforce.com' }) expect(proxyRequest.setHeader).not.toHaveBeenCalled() @@ -241,8 +210,7 @@ describe('applyScapiAuthHeaders', () => { incomingRequest, caching: false, siteId: 'RefArch', - targetHost: 'external-api.example.com', - slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + targetHost: 'external-api.example.com' }) expect(proxyRequest.setHeader).not.toHaveBeenCalled() @@ -265,8 +233,7 @@ describe('applyScapiAuthHeaders', () => { incomingRequest, caching: false, siteId: 'RefArch', - targetHost: 'abc-001.api.commercecloud.salesforce.com', - slasEndpointsRequiringAccessToken: /\/oauth2\/logout/ + targetHost: 'abc-001.api.commercecloud.salesforce.com' }) 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 38662e64bd..cfb72c2383 100644 --- a/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js +++ b/packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js @@ -12,7 +12,6 @@ import {processExpressResponse} from './process-express-response' import {isRemote, localDevLog, verboseProxyLogging, isScapiDomain} from './utils' import logger from '../logger-instance' import {getEnvBasePath} from '../ssr-namespace-paths' -import {SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN} from '../../ssr/server/constants' export const ALLOWED_CACHING_PROXY_REQUEST_METHODS = ['HEAD', 'GET', 'OPTIONS'] @@ -37,8 +36,8 @@ const generalProxyPathRE = /^\/mobify\/proxy\/([^/]+)(\/.*)$/ * 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 + * 4. SLAS auth endpoints (/shopper/auth/*) are skipped — Bearer injection for SLAS + * is handled by the SLAS private client proxy in build-remote-server.js * 5. For non-SLAS auth endpoints (e.g., /shopper/products, /shopper/baskets): Always apply Bearer token * * @private @@ -48,15 +47,13 @@ const generalProxyPathRE = /^\/mobify\/proxy\/([^/]+)(\/.*)$/ * @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 + targetHost }) => { const url = incomingRequest.url @@ -64,16 +61,10 @@ export const applyScapiAuthHeaders = ({ if (caching || !siteId || !isScapiDomain(targetHost) || !url) { return } + // SLAS auth endpoints are handled by the SLAS private client proxy 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 - } + 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 @@ -185,7 +176,6 @@ export const applyProxyRequestHeaders = ({ * @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 = ({ @@ -195,8 +185,7 @@ export const configureProxy = ({ targetHost, appProtocol = /* istanbul ignore next */ 'https', caching, - siteId = null, - slasEndpointsRequiringAccessToken = SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN + siteId = null }) => { // This configuration must match the behaviour of the proxying // in CloudFront. @@ -279,8 +268,7 @@ export const configureProxy = ({ incomingRequest, caching, siteId, - targetHost, - slasEndpointsRequiringAccessToken + targetHost }) }, @@ -374,15 +362,9 @@ export const configureProxy = ({ * @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, - siteId = null, - slasEndpointsRequiringAccessToken = SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN -) => { +export const configureProxyConfigs = (appHostname, appProtocol, siteId = null) => { localDevLog('') proxyConfigs.forEach((config) => { localDevLog( @@ -395,8 +377,7 @@ export const configureProxyConfigs = ( appProtocol, appHostname, caching: false, - siteId, - slasEndpointsRequiringAccessToken + siteId }) config.cachingProxy = configureProxy({ proxyPath: config.cachingPath,