Skip to content

Commit c45512d

Browse files
authored
Resolve siteId dynamically via x-site-id header for multisite (#3700)
Resolve siteId dynamically via x-site-id header for multisite
1 parent 6dd7f28 commit c45512d

File tree

13 files changed

+242
-183
lines changed

13 files changed

+242
-183
lines changed

packages/pwa-kit-create-app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## [Unreleased]
22
- Add configuration flag `disableHttpOnlySessionCookies` to `ssrParameters` [#3635](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3635)
3+
- Add `x-site-id` request header to read HttpOnly cookies on the server [#3700](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3700)
34

45
## v3.17.0-dev
56
- Clear verdaccio npm cache during project generation [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)

packages/pwa-kit-create-app/assets/bootstrap/js/overrides/app/components/_app-config/index.jsx.hbs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ const AppConfig = ({children, locals = {}}) => {
5757
const {correlationId} = useCorrelationId()
5858
const headers = {
5959
'correlation-id': correlationId,
60-
sfdc_user_agent: sfdcUserAgent
60+
sfdc_user_agent: sfdcUserAgent,
61+
'x-site-id': locals.site?.id
6162
}
6263

6364
const commerceApiConfig = locals.appConfig.commerceAPI

packages/pwa-kit-create-app/assets/templates/@salesforce/retail-react-app/app/components/_app-config/index.jsx.hbs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ const AppConfig = ({children, locals = {}}) => {
5757
const {correlationId} = useCorrelationId()
5858
const headers = {
5959
'correlation-id': correlationId,
60-
sfdc_user_agent: sfdcUserAgent
60+
sfdc_user_agent: sfdcUserAgent,
61+
'x-site-id': locals.site?.id
6162
}
6263

6364
const commerceApiConfig = locals.appConfig.commerceAPI

packages/pwa-kit-runtime/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
## [Unreleased]
22
- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680)
33
- Handle logout when HttpOnly session cookies is enabled [#3699](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3699)
4+
- Add `x-site-id` request header to read HttpOnly cookies on the server [#3700](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3700)
45

56
## v3.17.0-dev
67
- 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)

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

Lines changed: 48 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
X_ENCODED_HEADERS,
1717
CONTENT_SECURITY_POLICY,
1818
SLAS_TOKEN_RESPONSE_ENDPOINTS,
19-
SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN
19+
SLAS_LOGOUT_ENDPOINT
2020
} from './constants'
2121
import {
2222
catchAndLog,
@@ -64,7 +64,7 @@ import {ApiGatewayV1Adapter} from '@h4ad/serverless-adapter/lib/adapters/aws'
6464
import {ExpressFramework} from '@h4ad/serverless-adapter/lib/frameworks/express'
6565
import {is as typeis} from 'type-is'
6666
import {getConfig} from '../../utils/ssr-config'
67-
import {applyHttpOnlySessionCookies} from './process-token-response'
67+
import {setHttpOnlySessionCookies} from './process-token-response'
6868

6969
/**
7070
* An Array of mime-types (Content-Type values) that are considered
@@ -94,6 +94,47 @@ export const isBinary = (headers) => {
9494
return isContentTypeBinary(headers)
9595
}
9696

97+
/**
98+
* Inject Bearer token and refresh token from HttpOnly cookies for the SLAS logout endpoint.
99+
* Reads the access token and refresh token from cookies keyed by siteId (from the x-site-id header),
100+
* sets the Authorization header, and appends refresh_token to the query string.
101+
* @private
102+
*/
103+
export const setTokensInLogoutRequest = (proxyRequest, incomingRequest) => {
104+
const cookieHeader = incomingRequest.headers.cookie
105+
if (!cookieHeader) return
106+
107+
const cookies = cookie.parse(cookieHeader)
108+
const siteId = incomingRequest.headers['x-site-id']
109+
if (!siteId) {
110+
logger.warn(
111+
'x-site-id header is missing on SLAS logout request. ' +
112+
'Token injection skipped. ' +
113+
'Ensure the x-site-id header is set in CommerceApiProvider headers.',
114+
{namespace: 'setTokensInLogoutRequest'}
115+
)
116+
return
117+
}
118+
119+
// Inject Bearer token from access token cookie
120+
const accessToken = cookies[`cc-at_${siteId}`]
121+
if (accessToken) {
122+
proxyRequest.setHeader('Authorization', `Bearer ${accessToken}`)
123+
}
124+
125+
// Inject refresh_token into query string from HttpOnly cookie
126+
const refreshToken = cookies[`cc-nx_${siteId}`]
127+
if (refreshToken) {
128+
const separator = proxyRequest.path.includes('?') ? '&' : '?'
129+
proxyRequest.path += `${separator}refresh_token=${encodeURIComponent(refreshToken)}`
130+
} else {
131+
logger.warn(
132+
`Refresh token cookie (cc-nx_${siteId}) not found for ${incomingRequest.path}. The logout request may fail.`,
133+
{namespace: 'setTokensInLogoutRequest'}
134+
)
135+
}
136+
}
137+
97138
/**
98139
* Environment variables that must be set for the Express app to run remotely.
99140
*
@@ -170,12 +211,6 @@ export const RemoteServerFactory = {
170211
// cookies applied when that feature is enabled. Users can override this in ssr.js.
171212
tokenResponseEndpoints: SLAS_TOKEN_RESPONSE_ENDPOINTS,
172213

173-
// A regex for identifying which SLAS auth endpoints (/shopper/auth/) require the
174-
// shopper's access token in the Authorization header (Bearer token from HttpOnly cookie).
175-
// Most SLAS auth endpoints use Basic Auth with client credentials, but some like logout
176-
// require the shopper's Bearer token. Users can override this in ssr.js.
177-
slasEndpointsRequiringAccessToken: SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN,
178-
179214
// Custom callback to modify the SLAS private client proxy request. This callback is invoked
180215
// after the built-in proxy request handling. Users can provide additional
181216
// request modifications (e.g., custom headers).
@@ -233,16 +268,6 @@ export const RemoteServerFactory = {
233268
// Note: HttpOnly session cookies are controlled by the MRT_DISABLE_HTTPONLY_SESSION_COOKIES
234269
// env var (set by MRT in production, pwa-kit-dev locally). Read directly where needed.
235270

236-
// Extract siteId from app configuration for SCAPI auth
237-
// This will be used to read the correct access token cookie
238-
try {
239-
const config = getConfig({buildDirectory: options.buildDir})
240-
options.siteId = config?.app?.commerceAPI?.parameters?.siteId || null
241-
} catch (e) {
242-
// Config may not be available yet (e.g., during build), that's okay
243-
options.siteId = null
244-
}
245-
246271
return options
247272
},
248273

@@ -399,8 +424,7 @@ export const RemoteServerFactory = {
399424
* @private
400425
*/
401426
_configureProxyConfigs(options) {
402-
const siteId = options.siteId || null
403-
configureProxyConfigs(options.appHostname, options.protocol, siteId)
427+
configureProxyConfigs(options.appHostname, options.protocol)
404428
},
405429

406430
/**
@@ -979,39 +1003,9 @@ export const RemoteServerFactory = {
9791003
proxyRequest.setHeader('Authorization', `Basic ${encodedSlasCredentials}`)
9801004
} else if (
9811005
process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES === 'false' &&
982-
incomingRequest.path?.match(options.slasEndpointsRequiringAccessToken)
1006+
incomingRequest.path?.match(SLAS_LOGOUT_ENDPOINT)
9831007
) {
984-
// Inject tokens from HttpOnly cookies for endpoints like /oauth2/logout
985-
const cookieHeader = incomingRequest.headers.cookie
986-
if (cookieHeader) {
987-
const cookies = cookie.parse(cookieHeader)
988-
const siteId = options.mobify?.app?.commerceAPI?.parameters?.siteId
989-
if (siteId) {
990-
const site = siteId.trim()
991-
992-
// Inject Bearer token from access token cookie
993-
const accessToken = cookies[`cc-at_${site}`]
994-
if (accessToken) {
995-
proxyRequest.setHeader('Authorization', `Bearer ${accessToken}`)
996-
}
997-
998-
// Inject refresh_token into query string from HttpOnly cookie
999-
// refresh_token ishouls required for /oauth2/logout
1000-
const refreshToken = cookies[`cc-nx_${site}`]
1001-
if (refreshToken) {
1002-
const url = new URL(proxyRequest.path, 'http://localhost')
1003-
url.searchParams.set('refresh_token', refreshToken)
1004-
proxyRequest.path = url.pathname + url.search
1005-
} else {
1006-
logger.warn(
1007-
`Registered refresh token cookie (cc-nx_${site}) not found for ${incomingRequest.path}. The logout request may fail.`,
1008-
{
1009-
namespace: '_setupSlasPrivateClientProxy'
1010-
}
1011-
)
1012-
}
1013-
}
1014-
}
1008+
setTokensInLogoutRequest(proxyRequest, incomingRequest)
10151009
}
10161010

10171011
// Allow users to apply additional custom modifications to the proxy request
@@ -1043,7 +1037,7 @@ export const RemoteServerFactory = {
10431037
isTokenEndpoint
10441038
) {
10451039
try {
1046-
workingBuffer = applyHttpOnlySessionCookies(
1040+
workingBuffer = setHttpOnlySessionCookies(
10471041
workingBuffer,
10481042
proxyRes,
10491043
req,
@@ -1499,10 +1493,7 @@ export const RemoteServerFactory = {
14991493
* @param {RegExp} [options.tokenResponseEndpoints] - A regex pattern to match SLAS endpoints
15001494
* that return tokens in the response body. Used to determine which responses should have HttpOnly
15011495
* session cookies applied. Defaults to /\/oauth2\/(token|passwordless\/token)$/.
1502-
* @param {RegExp} [options.slasEndpointsRequiringAccessToken] - A regex pattern to match SLAS auth
1503-
* endpoints (/shopper/auth/) that require the shopper's access token in the Authorization header (Bearer token).
1504-
* Most SLAS auth endpoints use Basic Auth with client credentials, but some like logout require the shopper's
1505-
* Bearer token. Defaults to /\/oauth2\/logout/.
1496+
15061497
* @param {function} [options.onSLASPrivateProxyReq] - Custom callback to modify SLAS private client
15071498
* proxy requests. Called after built-in request handling. Signature: (proxyRequest, incomingRequest, res) => void.
15081499
* Use this to add custom headers or modify the proxy request.

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

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,7 @@ describe('HttpOnly session cookies', () => {
527527
'Cookie',
528528
'cc-at_testsite=mock-access-token; cc-nx_testsite=mock-refresh-token'
529529
)
530+
.set('x-site-id', 'testsite')
530531

531532
expect(response.status).toBe(200)
532533
expect(response.body.success).toBe(true)
@@ -538,6 +539,62 @@ describe('HttpOnly session cookies', () => {
538539
}
539540
})
540541

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

@@ -580,9 +637,9 @@ describe('HttpOnly session cookies', () => {
580637

581638
RemoteServerFactory._setupSlasPrivateClientProxy(app, options)
582639

583-
const response = await request(app).post(
584-
'/mobify/slas/private/shopper/auth/v1/oauth2/token'
585-
)
640+
const response = await request(app)
641+
.post('/mobify/slas/private/shopper/auth/v1/oauth2/token')
642+
.set('x-site-id', 'testsite')
586643

587644
expect(response.status).toBe(200)
588645
expect(response.body).not.toHaveProperty('access_token')
@@ -636,9 +693,9 @@ describe('HttpOnly session cookies', () => {
636693

637694
RemoteServerFactory._setupSlasPrivateClientProxy(app, options)
638695

639-
const response = await request(app).post(
640-
'/mobify/slas/private/shopper/auth/v1/oauth2/token'
641-
)
696+
const response = await request(app)
697+
.post('/mobify/slas/private/shopper/auth/v1/oauth2/token')
698+
.set('x-site-id', 'testsite')
642699

643700
expect(response.status).toBe(500)
644701
expect(response.body.error).toBe('Internal server error')
@@ -690,9 +747,9 @@ describe('HttpOnly session cookies', () => {
690747

691748
RemoteServerFactory._setupSlasPrivateClientProxy(app, options)
692749

693-
const response = await request(app).post(
694-
'/mobify/slas/private/shopper/auth/v1/oauth2/passwordless/token'
695-
)
750+
const response = await request(app)
751+
.post('/mobify/slas/private/shopper/auth/v1/oauth2/passwordless/token')
752+
.set('x-site-id', 'testsite')
696753

697754
expect(response.status).toBe(200)
698755
expect(response.body).not.toHaveProperty('access_token')

packages/pwa-kit-runtime/src/ssr/server/constants.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const SLAS_CUSTOM_PROXY_PATH = '/mobify/slas/private'
3131
// Users can override these in their project's ssr.js options.
3232
export const SLAS_TOKEN_RESPONSE_ENDPOINTS = /\/oauth2\/(token|passwordless\/token)$/
3333

34-
// Default regex patterns for SLAS endpoints that need access token in authorization header, used when httpOnly session cookies are enabled
35-
// Users can override these in their project's ssr.js options.
36-
export const SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN = /\/oauth2\/logout/
34+
// Default regex pattern for the SLAS logout endpoint, used when httpOnly session cookies are enabled
35+
// to inject Bearer token and refresh token from HttpOnly cookies.
36+
// Users can override this in their project's ssr.js options.
37+
export const SLAS_LOGOUT_ENDPOINT = /\/oauth2\/logout/

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,12 @@ function getTokenClaims(accessToken) {
6767
* strip token fields from body, and append our Set-Cookie headers (preserving upstream cookies).
6868
* @private
6969
*/
70-
export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options) {
71-
const siteId = options.mobify?.app?.commerceAPI?.parameters?.siteId
72-
if (!siteId || typeof siteId !== 'string' || siteId.trim() === '') {
70+
export function setHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options) {
71+
const siteId = req.headers?.['x-site-id']
72+
if (!siteId) {
7373
throw new Error(
7474
'HttpOnly session cookies are enabled but siteId is missing. ' +
75-
'Set mobify.app.commerceAPI.parameters.siteId in your app config.'
75+
'Ensure the x-site-id header is set on the request.'
7676
)
7777
}
7878

@@ -83,7 +83,7 @@ export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res,
8383
return responseBuffer
8484
}
8585

86-
const site = siteId.trim()
86+
const site = siteId
8787

8888
// Decode JWT and extract claims
8989
let isGuest = true

0 commit comments

Comments
 (0)