diff --git a/packages/pwa-kit-create-app/CHANGELOG.md b/packages/pwa-kit-create-app/CHANGELOG.md index fd4f0bae3c..ccc004b7ff 100644 --- a/packages/pwa-kit-create-app/CHANGELOG.md +++ b/packages/pwa-kit-create-app/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] - Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635) +- Add `x-site-id` request header to read HttpOnly cookies on the server [#3700](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3700) ## v3.17.0-dev - Clear verdaccio npm cache during project generation [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) diff --git a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs index 0592183a90..4ee4151c22 100644 --- a/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs +++ b/packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs @@ -57,7 +57,8 @@ const AppConfig = ({children, locals = {}}) => { const {correlationId} = useCorrelationId() const headers = { 'correlation-id': correlationId, - sfdc_user_agent: sfdcUserAgent + sfdc_user_agent: sfdcUserAgent, + 'x-site-id': locals.site?.id } const commerceApiConfig = locals.appConfig.commerceAPI diff --git a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs index bebebc4f56..669a08e995 100644 --- a/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs +++ b/packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs @@ -57,7 +57,8 @@ const AppConfig = ({children, locals = {}}) => { const {correlationId} = useCorrelationId() const headers = { 'correlation-id': correlationId, - sfdc_user_agent: sfdcUserAgent + sfdc_user_agent: sfdcUserAgent, + 'x-site-id': locals.site?.id } const commerceApiConfig = locals.appConfig.commerceAPI diff --git a/packages/pwa-kit-runtime/CHANGELOG.md b/packages/pwa-kit-runtime/CHANGELOG.md index 074f6e7851..309c65ee6e 100644 --- a/packages/pwa-kit-runtime/CHANGELOG.md +++ b/packages/pwa-kit-runtime/CHANGELOG.md @@ -1,6 +1,7 @@ ## [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) +- Add `x-site-id` request header to read HttpOnly cookies on the server [#3700](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3700) ## 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 36c31cdea5..56a86dcdd5 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 @@ -16,7 +16,7 @@ import { X_ENCODED_HEADERS, CONTENT_SECURITY_POLICY, SLAS_TOKEN_RESPONSE_ENDPOINTS, - SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN + SLAS_LOGOUT_ENDPOINT } from './constants' import { catchAndLog, @@ -64,7 +64,7 @@ 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' +import {setHttpOnlySessionCookies} from './process-token-response' /** * An Array of mime-types (Content-Type values) that are considered @@ -94,6 +94,47 @@ export const isBinary = (headers) => { return isContentTypeBinary(headers) } +/** + * Inject Bearer token and refresh token from HttpOnly cookies for the SLAS logout endpoint. + * Reads the access token and refresh token from cookies keyed by siteId (from the x-site-id header), + * sets the Authorization header, and appends refresh_token to the query string. + * @private + */ +export const setTokensInLogoutRequest = (proxyRequest, incomingRequest) => { + const cookieHeader = incomingRequest.headers.cookie + if (!cookieHeader) return + + const cookies = cookie.parse(cookieHeader) + const siteId = incomingRequest.headers['x-site-id'] + if (!siteId) { + logger.warn( + 'x-site-id header is missing on SLAS logout request. ' + + 'Token injection skipped. ' + + 'Ensure the x-site-id header is set in CommerceApiProvider headers.', + {namespace: 'setTokensInLogoutRequest'} + ) + return + } + + // Inject Bearer token from access token cookie + const accessToken = cookies[`cc-at_${siteId}`] + if (accessToken) { + proxyRequest.setHeader('Authorization', `Bearer ${accessToken}`) + } + + // Inject refresh_token into query string from HttpOnly cookie + const refreshToken = cookies[`cc-nx_${siteId}`] + if (refreshToken) { + const separator = proxyRequest.path.includes('?') ? '&' : '?' + proxyRequest.path += `${separator}refresh_token=${encodeURIComponent(refreshToken)}` + } else { + logger.warn( + `Refresh token cookie (cc-nx_${siteId}) not found for ${incomingRequest.path}. The logout request may fail.`, + {namespace: 'setTokensInLogoutRequest'} + ) + } +} + /** * Environment variables that must be set for the Express app to run remotely. * @@ -170,12 +211,6 @@ export const RemoteServerFactory = { // 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). @@ -233,16 +268,6 @@ export const RemoteServerFactory = { // 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 }, @@ -399,8 +424,7 @@ export const RemoteServerFactory = { * @private */ _configureProxyConfigs(options) { - const siteId = options.siteId || null - configureProxyConfigs(options.appHostname, options.protocol, siteId) + configureProxyConfigs(options.appHostname, options.protocol) }, /** @@ -979,39 +1003,9 @@ export const RemoteServerFactory = { proxyRequest.setHeader('Authorization', `Basic ${encodedSlasCredentials}`) } else if ( process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES === 'false' && - incomingRequest.path?.match(options.slasEndpointsRequiringAccessToken) + incomingRequest.path?.match(SLAS_LOGOUT_ENDPOINT) ) { - // 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' - } - ) - } - } - } + setTokensInLogoutRequest(proxyRequest, incomingRequest) } // Allow users to apply additional custom modifications to the proxy request @@ -1043,7 +1037,7 @@ export const RemoteServerFactory = { isTokenEndpoint ) { try { - workingBuffer = applyHttpOnlySessionCookies( + workingBuffer = setHttpOnlySessionCookies( workingBuffer, proxyRes, req, @@ -1499,10 +1493,7 @@ export const RemoteServerFactory = { * @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 a6084a5dc6..95899f0af4 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 @@ -527,6 +527,7 @@ describe('HttpOnly session cookies', () => { 'Cookie', 'cc-at_testsite=mock-access-token; cc-nx_testsite=mock-refresh-token' ) + .set('x-site-id', 'testsite') expect(response.status).toBe(200) expect(response.body.success).toBe(true) @@ -538,6 +539,62 @@ describe('HttpOnly session cookies', () => { } }) + test('x-site-id header takes precedence over static config siteId 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}) + }) + + const mockSlasServerInstance = mockSlasServer.listen(0) + const mockSlasPort = mockSlasServerInstance.address().port + + try { + const app = mockExpress() + // Static config has siteId 'default-site', but x-site-id header will be 'othersite' + 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: 'default-site' + } + } + } + } + }) + + process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret' + + RemoteServerFactory._setupSlasPrivateClientProxy(app, options) + + // Cookies are keyed to 'othersite', and x-site-id header says 'othersite' + const response = await request(app) + .post('/mobify/slas/private/shopper/auth/v1/oauth2/logout') + .set( + 'Cookie', + 'cc-at_othersite=other-access-token; cc-nx_othersite=other-refresh-token' + ) + .set('x-site-id', 'othersite') + + expect(response.status).toBe(200) + expect(capturedAuthHeader).toBe('Bearer other-access-token') + expect(capturedRefreshToken).toBe('other-refresh-token') + } finally { + mockSlasServerInstance.close() + } + }) + test('sets HttpOnly cookies and strips tokens from response body', async () => { process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false' @@ -580,9 +637,9 @@ describe('HttpOnly session cookies', () => { RemoteServerFactory._setupSlasPrivateClientProxy(app, options) - const response = await request(app).post( - '/mobify/slas/private/shopper/auth/v1/oauth2/token' - ) + const response = await request(app) + .post('/mobify/slas/private/shopper/auth/v1/oauth2/token') + .set('x-site-id', 'testsite') expect(response.status).toBe(200) expect(response.body).not.toHaveProperty('access_token') @@ -636,9 +693,9 @@ describe('HttpOnly session cookies', () => { RemoteServerFactory._setupSlasPrivateClientProxy(app, options) - const response = await request(app).post( - '/mobify/slas/private/shopper/auth/v1/oauth2/token' - ) + const response = await request(app) + .post('/mobify/slas/private/shopper/auth/v1/oauth2/token') + .set('x-site-id', 'testsite') expect(response.status).toBe(500) expect(response.body.error).toBe('Internal server error') @@ -690,9 +747,9 @@ describe('HttpOnly session cookies', () => { RemoteServerFactory._setupSlasPrivateClientProxy(app, options) - const response = await request(app).post( - '/mobify/slas/private/shopper/auth/v1/oauth2/passwordless/token' - ) + const response = await request(app) + .post('/mobify/slas/private/shopper/auth/v1/oauth2/passwordless/token') + .set('x-site-id', 'testsite') expect(response.status).toBe(200) expect(response.body).not.toHaveProperty('access_token') diff --git a/packages/pwa-kit-runtime/src/ssr/server/constants.js b/packages/pwa-kit-runtime/src/ssr/server/constants.js index 654adc8b80..dbf997734c 100644 --- a/packages/pwa-kit-runtime/src/ssr/server/constants.js +++ b/packages/pwa-kit-runtime/src/ssr/server/constants.js @@ -31,6 +31,7 @@ export const SLAS_CUSTOM_PROXY_PATH = '/mobify/slas/private' // 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/ +// Default regex pattern for the SLAS logout endpoint, used when httpOnly session cookies are enabled +// to inject Bearer token and refresh token from HttpOnly cookies. +// Users can override this in their project's ssr.js options. +export const SLAS_LOGOUT_ENDPOINT = /\/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 index 6572e00788..879d7936cd 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 @@ -67,12 +67,12 @@ function getTokenClaims(accessToken) { * 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() === '') { +export function setHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options) { + const siteId = req.headers?.['x-site-id'] + if (!siteId) { throw new Error( 'HttpOnly session cookies are enabled but siteId is missing. ' + - 'Set mobify.app.commerceAPI.parameters.siteId in your app config.' + 'Ensure the x-site-id header is set on the request.' ) } @@ -83,7 +83,7 @@ export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, return responseBuffer } - const site = siteId.trim() + const site = siteId // Decode JWT and extract claims let isGuest = true 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 1d816f5401..c31e8293ef 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 @@ -4,7 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import {getRefreshTokenCookieTTL, applyHttpOnlySessionCookies} from './process-token-response' +import {getRefreshTokenCookieTTL, setHttpOnlySessionCookies} from './process-token-response' import {parse as parseSetCookie} from 'set-cookie-parser' jest.mock('../../utils/logger-instance', () => ({ @@ -24,16 +24,8 @@ function makeJWT(payload) { return `${header}.${payloadPart}.sig` } -function makeOptions(siteId = 'testsite') { - return { - mobify: { - app: { - commerceAPI: { - parameters: {siteId} - } - } - } - } +function makeReq(siteId = 'testsite') { + return {headers: {'x-site-id': siteId}} } function makeRes() { @@ -122,31 +114,22 @@ describe('getRefreshTokenCookieTTL', () => { }) }) -describe('applyHttpOnlySessionCookies', () => { +describe('setHttpOnlySessionCookies', () => { 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', () => { + test('throws when x-site-id header is missing', () => { const res = makeRes() const buf = makeResponseBuffer({access_token: 'x'}) - expect(() => applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions(' '))).toThrow( - /siteId is missing/ - ) + const req = {headers: {}} + expect(() => setHttpOnlySessionCookies(buf, {}, req, res, {})).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()) + const result = setHttpOnlySessionCookies(buf, {}, makeReq(), res, {}) expect(result).toBe(buf) expect(res.append).not.toHaveBeenCalled() }) @@ -166,7 +149,7 @@ describe('applyHttpOnlySessionCookies', () => { expires_in: 1800, customer_id: 'cust123' }) - const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + const result = setHttpOnlySessionCookies(buf, {}, makeReq(), res, {}) // cc-at: access token (HttpOnly) const atCookie = parseCookie(res.cookies.find((c) => c.includes('cc-at_testsite='))) @@ -232,7 +215,7 @@ describe('applyHttpOnlySessionCookies', () => { refresh_token: 'refresh-value', expires_in: 1800 }) - const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + const result = setHttpOnlySessionCookies(buf, {}, makeReq(), res, {}) // cc-at (HttpOnly) const atCookie = parseCookie(res.cookies.find((c) => c.includes('cc-at_testsite='))) @@ -277,7 +260,7 @@ describe('applyHttpOnlySessionCookies', () => { refresh_token: 'refresh-value', expires_in: 1800 }) - applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions()) + setHttpOnlySessionCookies(buf, {}, makeReq(), res, {}) expect(res.cookies.find((c) => c.includes('uido_testsite'))).toBeUndefined() }) @@ -285,7 +268,7 @@ describe('applyHttpOnlySessionCookies', () => { 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( + expect(() => setHttpOnlySessionCookies(buf, {}, makeReq(), res, {})).toThrow( /Failed to decode access token JWT/ ) }) @@ -294,7 +277,7 @@ describe('applyHttpOnlySessionCookies', () => { 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()) + setHttpOnlySessionCookies(buf, {}, makeReq(), res, {}) const expCookie = res.cookies.find((c) => c.includes('cc-at-expires_testsite=')) const parsed = parseCookie(expCookie) @@ -304,22 +287,20 @@ describe('applyHttpOnlySessionCookies', () => { 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 result = setHttpOnlySessionCookies(buf, {}, makeReq(), res, {}) 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', () => { + test('uses x-site-id header to resolve correct cookie names', () => { const res = makeRes() - const accessToken = makeJWT({iat: 1000, isb: 'uido:ecom::upn:Guest'}) + const accessToken = makeJWT({iat: 1000, exp: 2800, isb: 'uido:ecom::upn:Guest'}) const buf = makeResponseBuffer({access_token: accessToken, expires_in: 1800}) - applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions(' mysite ')) + setHttpOnlySessionCookies(buf, {}, makeReq('othersite'), res, {}) - const atCookie = res.cookies.find((c) => c.includes('cc-at_mysite=')) + const atCookie = res.cookies.find((c) => c.includes('cc-at_othersite=')) 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 73491d14db..fd90511bc6 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,7 +4,11 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import {applyProxyRequestHeaders, applyScapiAuthHeaders, configureProxy} from './configure-proxy' +import { + applyProxyRequestHeaders, + setScapiAuthRequestHeaders, + configureProxy +} from './configure-proxy' import * as ssrProxying from '../ssr-proxying' import * as utils from './utils' import cookie from 'cookie' @@ -14,6 +18,16 @@ jest.mock('./utils', () => ({ ...jest.requireActual('./utils'), isScapiDomain: jest.fn() })) +jest.mock('../logger-instance', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + info: jest.fn(), + error: jest.fn() + } +})) + +import logger from '../logger-instance' describe('applyProxyRequestHeaders', () => { it('removes a header not present in new headers', () => { @@ -90,7 +104,7 @@ describe('configureProxy ALLOWED_CACHING_PROXY_REQUEST_METHODS', () => { }) }) -describe('applyScapiAuthHeaders', () => { +describe('setScapiAuthRequestHeaders', () => { beforeEach(() => { jest.clearAllMocks() }) @@ -105,14 +119,16 @@ describe('applyScapiAuthHeaders', () => { } const incomingRequest = { url: '/shopper/products/v1/products', - headers: {cookie: 'cc-at_RefArch=test-access-token'} + headers: { + cookie: 'cc-at_RefArch=test-access-token', + 'x-site-id': 'RefArch' + } } - applyScapiAuthHeaders({ + setScapiAuthRequestHeaders({ proxyRequest, incomingRequest, caching: false, - siteId: 'RefArch', targetHost: 'abc-001.api.commercecloud.salesforce.com' }) @@ -122,30 +138,6 @@ describe('applyScapiAuthHeaders', () => { ) }) - it('skips all SLAS auth endpoints (handled by SLAS private proxy)', () => { - 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' - }) - - // SLAS auth endpoints are handled by the SLAS private client proxy - 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'}) @@ -155,14 +147,16 @@ describe('applyScapiAuthHeaders', () => { } const incomingRequest = { url: '/shopper/products/v1/products', - headers: {cookie: 'cc-at_RefArch=test-access-token'} + headers: { + cookie: 'cc-at_RefArch=test-access-token', + 'x-site-id': 'RefArch' + } } - applyScapiAuthHeaders({ + setScapiAuthRequestHeaders({ proxyRequest, incomingRequest, caching: true, - siteId: 'RefArch', targetHost: 'abc-001.api.commercecloud.salesforce.com' }) @@ -170,7 +164,7 @@ describe('applyScapiAuthHeaders', () => { expect(proxyRequest.setHeader).not.toHaveBeenCalled() }) - it('does not apply Bearer token when siteId is not provided', () => { + it('logs warning and skips when x-site-id header is missing on SCAPI request', () => { utils.isScapiDomain.mockReturnValue(true) cookie.parse.mockReturnValue({}) @@ -182,15 +176,18 @@ describe('applyScapiAuthHeaders', () => { headers: {} } - applyScapiAuthHeaders({ + setScapiAuthRequestHeaders({ proxyRequest, incomingRequest, caching: false, - siteId: null, targetHost: 'abc-001.api.commercecloud.salesforce.com' }) expect(proxyRequest.setHeader).not.toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('x-site-id header is missing'), + expect.any(Object) + ) }) it('does not apply Bearer token when target is not SCAPI domain', () => { @@ -202,14 +199,16 @@ describe('applyScapiAuthHeaders', () => { } const incomingRequest = { url: '/api/products', - headers: {cookie: 'cc-at_RefArch=test-access-token'} + headers: { + cookie: 'cc-at_RefArch=test-access-token', + 'x-site-id': 'RefArch' + } } - applyScapiAuthHeaders({ + setScapiAuthRequestHeaders({ proxyRequest, incomingRequest, caching: false, - siteId: 'RefArch', targetHost: 'external-api.example.com' }) @@ -225,17 +224,45 @@ describe('applyScapiAuthHeaders', () => { } const incomingRequest = { url: '/shopper/products/v1/products', - headers: {} + headers: {'x-site-id': 'RefArch'} } - applyScapiAuthHeaders({ + setScapiAuthRequestHeaders({ proxyRequest, incomingRequest, caching: false, - siteId: 'RefArch', targetHost: 'abc-001.api.commercecloud.salesforce.com' }) expect(proxyRequest.setHeader).not.toHaveBeenCalled() }) + + it('uses x-site-id header to resolve correct cookie', () => { + utils.isScapiDomain.mockReturnValue(true) + cookie.parse.mockReturnValue({'cc-at_OtherSite': 'other-access-token'}) + + const proxyRequest = { + setHeader: jest.fn(), + removeHeader: jest.fn() + } + const incomingRequest = { + url: '/shopper/products/v1/products', + headers: { + cookie: 'cc-at_OtherSite=other-access-token', + 'x-site-id': 'OtherSite' + } + } + + setScapiAuthRequestHeaders({ + proxyRequest, + incomingRequest, + caching: false, + targetHost: 'abc-001.api.commercecloud.salesforce.com' + }) + + expect(proxyRequest.setHeader).toHaveBeenCalledWith( + 'authorization', + 'Bearer other-access-token' + ) + }) }) 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 cfb72c2383..504bcb6ecc 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 @@ -34,35 +34,35 @@ const generalProxyPathRE = /^\/mobify\/proxy\/([^/]+)(\/.*)$/ * * Logic for determining if Bearer token should be applied: * 1. Caching proxies never use auth (skip) - * 2. siteId must be provided (skip if not) + * 2. x-site-id header must be present (skip if not) * 3. Target must be SCAPI domain (skip if not) - * 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 * @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) */ -export const applyScapiAuthHeaders = ({ +export const setScapiAuthRequestHeaders = ({ proxyRequest, incomingRequest, caching, - siteId, targetHost }) => { const url = incomingRequest.url + const resolvedSiteId = incomingRequest.headers?.['x-site-id'] - // Skip if: caching proxy, no siteId, not SCAPI domain, or no URL - if (caching || !siteId || !isScapiDomain(targetHost) || !url) { + // Skip if: caching proxy, not SCAPI domain, or no URL + if (caching || !isScapiDomain(targetHost) || !url) { return } - // SLAS auth endpoints are handled by the SLAS private client proxy - if (url.startsWith('/shopper/auth/')) { + + if (!resolvedSiteId) { + logger.warn( + 'x-site-id header is missing on SCAPI proxy request. Bearer token injection skipped.', + {namespace: 'configureProxy.setScapiAuthRequestHeaders'} + ) return } @@ -71,7 +71,7 @@ export const applyScapiAuthHeaders = ({ if (!cookieHeader) return const cookies = cookie.parse(cookieHeader) - const tokenKey = `cc-at_${siteId.trim()}` + const tokenKey = `cc-at_${resolvedSiteId}` const accessToken = cookies[tokenKey] if (accessToken) { @@ -175,7 +175,6 @@ 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 * @returns {middleware} function to pass to expressApp.use() */ export const configureProxy = ({ @@ -184,8 +183,7 @@ export const configureProxy = ({ targetProtocol, targetHost, appProtocol = /* istanbul ignore next */ 'https', - caching, - siteId = null + caching }) => { // This configuration must match the behaviour of the proxying // in CloudFront. @@ -263,13 +261,14 @@ export const configureProxy = ({ }) // Apply Authorization header with shopper's access token from HttpOnly cookie - applyScapiAuthHeaders({ - proxyRequest, - incomingRequest, - caching, - siteId, - targetHost - }) + if (process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES === 'false') { + setScapiAuthRequestHeaders({ + proxyRequest, + incomingRequest, + caching, + targetHost + }) + } }, onProxyRes: (proxyResponse, req) => { @@ -361,10 +360,9 @@ 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 * @private */ -export const configureProxyConfigs = (appHostname, appProtocol, siteId = null) => { +export const configureProxyConfigs = (appHostname, appProtocol) => { localDevLog('') proxyConfigs.forEach((config) => { localDevLog( @@ -376,8 +374,7 @@ export const configureProxyConfigs = (appHostname, appProtocol, siteId = null) = targetHost: config.host, appProtocol, appHostname, - caching: false, - siteId + caching: false }) config.cachingProxy = configureProxy({ proxyPath: config.cachingPath, @@ -385,8 +382,7 @@ export const configureProxyConfigs = (appHostname, appProtocol, siteId = null) = targetHost: config.host, appProtocol, appHostname, - caching: true, - siteId: null // No auth for caching proxy + caching: true }) }) localDevLog('') diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index 6e036c0b5d..d604682ecb 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/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) +- Add `x-site-id` request header to read HttpOnly cookies on the server [#3700](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3700) ## v9.1.0-dev - Update jest-fetch-mock and Jest 29 dependencies [#3663](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3663) 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 e766d8e891..c2f0e03f3d 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 @@ -66,7 +66,8 @@ const AppConfig = ({children, locals = {}}) => { const {correlationId} = useCorrelationId() const headers = { 'correlation-id': correlationId, - sfdc_user_agent: sfdcUserAgent + sfdc_user_agent: sfdcUserAgent, + 'x-site-id': locals.site?.id } const commerceApiConfig = locals.appConfig.commerceAPI