Skip to content

Commit f83a50f

Browse files
unandyalaclaude
andcommitted
Resolve siteId dynamically via x-site-id header for multisite support
With multisite enabled, siteId is request-dependent but was previously read once at startup from static config, breaking HttpOnly cookie lookups for non-default sites. This adds an x-site-id header from the per-request resolved site and reads it in the proxy layers with static config fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9ee940f commit f83a50f

File tree

7 files changed

+151
-5
lines changed

7 files changed

+151
-5
lines changed

packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -984,7 +984,10 @@ export const RemoteServerFactory = {
984984
const cookieHeader = incomingRequest.headers.cookie
985985
if (cookieHeader) {
986986
const cookies = cookie.parse(cookieHeader)
987-
const siteId = options.mobify?.app?.commerceAPI?.parameters?.siteId
987+
// Prefer per-request siteId from header, fall back to static config
988+
const siteId =
989+
incomingRequest.headers['x-site-id'] ||
990+
options.mobify?.app?.commerceAPI?.parameters?.siteId
988991
if (siteId) {
989992
const site = siteId.trim()
990993

packages/pwa-kit-runtime/src/ssr/server/build-remote-server.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,7 @@ describe('HttpOnly session cookies', () => {
524524
const response = await request(app)
525525
.post('/mobify/slas/private/shopper/auth/v1/oauth2/logout')
526526
.set('Cookie', 'cc-at_testsite=mock-access-token; cc-nx_testsite=mock-refresh-token')
527+
.set('x-site-id', 'testsite')
527528

528529
expect(response.status).toBe(200)
529530
expect(response.body.success).toBe(true)
@@ -535,6 +536,62 @@ describe('HttpOnly session cookies', () => {
535536
}
536537
})
537538

539+
test('x-site-id header takes precedence over static config siteId for logout endpoint', async () => {
540+
process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false'
541+
542+
let capturedAuthHeader
543+
let capturedRefreshToken
544+
const mockSlasServer = mockExpress()
545+
mockSlasServer.post('/shopper/auth/v1/oauth2/logout', (req, res) => {
546+
capturedAuthHeader = req.headers.authorization
547+
capturedRefreshToken = req.query.refresh_token
548+
res.status(200).json({success: true})
549+
})
550+
551+
const mockSlasServerInstance = mockSlasServer.listen(0)
552+
const mockSlasPort = mockSlasServerInstance.address().port
553+
554+
try {
555+
const app = mockExpress()
556+
// Static config has siteId 'default-site', but x-site-id header will be 'othersite'
557+
const options = RemoteServerFactory._configure({
558+
useSLASPrivateClient: true,
559+
slasTarget: `http://localhost:${mockSlasPort}`,
560+
mobify: {
561+
app: {
562+
commerceAPI: {
563+
parameters: {
564+
shortCode: 'test',
565+
organizationId: 'f_ecom_test',
566+
clientId: 'test-client-id',
567+
siteId: 'default-site'
568+
}
569+
}
570+
}
571+
}
572+
})
573+
574+
process.env.PWA_KIT_SLAS_CLIENT_SECRET = 'test-secret'
575+
576+
RemoteServerFactory._setupSlasPrivateClientProxy(app, options)
577+
578+
// Cookies are keyed to 'othersite', and x-site-id header says 'othersite'
579+
const response = await request(app)
580+
.post('/mobify/slas/private/shopper/auth/v1/oauth2/logout')
581+
.set(
582+
'Cookie',
583+
'cc-at_othersite=other-access-token; cc-nx_othersite=other-refresh-token'
584+
)
585+
.set('x-site-id', 'othersite')
586+
587+
expect(response.status).toBe(200)
588+
expect(capturedAuthHeader).toBe('Bearer other-access-token')
589+
expect(capturedRefreshToken).toBe('other-refresh-token')
590+
} finally {
591+
mockSlasServerInstance.close()
592+
}
593+
})
594+
538595
test('sets HttpOnly cookies and strips tokens from response body', async () => {
539596
process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false'
540597

packages/pwa-kit-runtime/src/ssr/server/process-token-response.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ function getTokenClaims(accessToken) {
6868
* @private
6969
*/
7070
export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options) {
71-
const siteId = options.mobify?.app?.commerceAPI?.parameters?.siteId
71+
// Prefer per-request siteId from header, fall back to static config
72+
const siteId =
73+
req.headers?.['x-site-id'] || options.mobify?.app?.commerceAPI?.parameters?.siteId
7274
if (!siteId || typeof siteId !== 'string' || siteId.trim() === '') {
7375
throw new Error(
7476
'HttpOnly session cookies are enabled but siteId is missing. ' +

packages/pwa-kit-runtime/src/ssr/server/process-token-response.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,4 +314,28 @@ describe('applyHttpOnlySessionCookies', () => {
314314
// No leading/trailing spaces in cookie name
315315
expect(res.cookies.find((c) => c.includes('cc-at_ mysite'))).toBeUndefined()
316316
})
317+
318+
test('x-site-id header overrides static config siteId', () => {
319+
const res = makeRes()
320+
const accessToken = makeJWT({iat: 1000, exp: 2800, isb: 'uido:ecom::upn:Guest'})
321+
const buf = makeResponseBuffer({access_token: accessToken, expires_in: 1800})
322+
const req = {headers: {'x-site-id': 'headersite'}}
323+
applyHttpOnlySessionCookies(buf, {}, req, res, makeOptions('configsite'))
324+
325+
// Should use 'headersite' from the x-site-id header, not 'configsite' from options
326+
const atCookie = res.cookies.find((c) => c.includes('cc-at_headersite='))
327+
expect(atCookie).toBeDefined()
328+
expect(res.cookies.find((c) => c.includes('cc-at_configsite='))).toBeUndefined()
329+
})
330+
331+
test('falls back to static config siteId when x-site-id header is absent', () => {
332+
const res = makeRes()
333+
const accessToken = makeJWT({iat: 1000, exp: 2800, isb: 'uido:ecom::upn:Guest'})
334+
const buf = makeResponseBuffer({access_token: accessToken, expires_in: 1800})
335+
const req = {headers: {}}
336+
applyHttpOnlySessionCookies(buf, {}, req, res, makeOptions('configsite'))
337+
338+
const atCookie = res.cookies.find((c) => c.includes('cc-at_configsite='))
339+
expect(atCookie).toBeDefined()
340+
})
317341
})

packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.basic.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,61 @@ describe('applyScapiAuthHeaders', () => {
238238

239239
expect(proxyRequest.setHeader).not.toHaveBeenCalled()
240240
})
241+
242+
it('x-site-id header overrides static siteId param', () => {
243+
utils.isScapiDomain.mockReturnValue(true)
244+
cookie.parse.mockReturnValue({'cc-at_OtherSite': 'other-access-token'})
245+
246+
const proxyRequest = {
247+
setHeader: jest.fn(),
248+
removeHeader: jest.fn()
249+
}
250+
const incomingRequest = {
251+
url: '/shopper/products/v1/products',
252+
headers: {
253+
cookie: 'cc-at_OtherSite=other-access-token',
254+
'x-site-id': 'OtherSite'
255+
}
256+
}
257+
258+
applyScapiAuthHeaders({
259+
proxyRequest,
260+
incomingRequest,
261+
caching: false,
262+
siteId: 'RefArch', // static fallback — should be overridden by x-site-id
263+
targetHost: 'abc-001.api.commercecloud.salesforce.com'
264+
})
265+
266+
expect(proxyRequest.setHeader).toHaveBeenCalledWith(
267+
'authorization',
268+
'Bearer other-access-token'
269+
)
270+
})
271+
272+
it('falls back to static siteId when x-site-id header is absent', () => {
273+
utils.isScapiDomain.mockReturnValue(true)
274+
cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'})
275+
276+
const proxyRequest = {
277+
setHeader: jest.fn(),
278+
removeHeader: jest.fn()
279+
}
280+
const incomingRequest = {
281+
url: '/shopper/products/v1/products',
282+
headers: {cookie: 'cc-at_RefArch=test-access-token'}
283+
}
284+
285+
applyScapiAuthHeaders({
286+
proxyRequest,
287+
incomingRequest,
288+
caching: false,
289+
siteId: 'RefArch',
290+
targetHost: 'abc-001.api.commercecloud.salesforce.com'
291+
})
292+
293+
expect(proxyRequest.setHeader).toHaveBeenCalledWith(
294+
'authorization',
295+
'Bearer test-access-token'
296+
)
297+
})
241298
})

packages/pwa-kit-runtime/src/utils/ssr-server/configure-proxy.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ export const applyScapiAuthHeaders = ({
5656
targetHost
5757
}) => {
5858
const url = incomingRequest.url
59+
// Prefer per-request siteId from header, fall back to static config
60+
const resolvedSiteId = incomingRequest.headers?.['x-site-id'] || siteId
5961

6062
// Skip if: caching proxy, no siteId, not SCAPI domain, or no URL
61-
if (caching || !siteId || !isScapiDomain(targetHost) || !url) {
63+
if (caching || !resolvedSiteId || !isScapiDomain(targetHost) || !url) {
6264
return
6365
}
6466
// SLAS auth endpoints are handled by the SLAS private client proxy
@@ -71,7 +73,7 @@ export const applyScapiAuthHeaders = ({
7173
if (!cookieHeader) return
7274

7375
const cookies = cookie.parse(cookieHeader)
74-
const tokenKey = `cc-at_${siteId.trim()}`
76+
const tokenKey = `cc-at_${resolvedSiteId.trim()}`
7577
const accessToken = cookies[tokenKey]
7678

7779
if (accessToken) {

packages/template-retail-react-app/app/components/_app-config/index.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ const AppConfig = ({children, locals = {}}) => {
6666
const {correlationId} = useCorrelationId()
6767
const headers = {
6868
'correlation-id': correlationId,
69-
sfdc_user_agent: sfdcUserAgent
69+
sfdc_user_agent: sfdcUserAgent,
70+
'x-site-id': locals.site?.id
7071
}
7172

7273
const commerceApiConfig = locals.appConfig.commerceAPI

0 commit comments

Comments
 (0)