Skip to content

Commit 6dd7f28

Browse files
authored
Add Bearer and refresh token injection for SLAS logout with HttpOnly cookies (#3699)
* Add Bearer token and refresh token injection for SLAS private client logout
1 parent a6632e2 commit 6dd7f28

File tree

7 files changed

+97
-84
lines changed

7 files changed

+97
-84
lines changed

packages/pwa-kit-runtime/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## [Unreleased]
22
- Add HttpOnly session cookies for SLAS private client proxy [#3680](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3680)
3+
- Handle logout when HttpOnly session cookies is enabled [#3699](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3699)
34

45
## v3.17.0-dev
56
- 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: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import path from 'path'
8+
import cookie from 'cookie'
89
import {
910
BUILD,
1011
CONTENT_TYPE,
@@ -399,13 +400,7 @@ export const RemoteServerFactory = {
399400
*/
400401
_configureProxyConfigs(options) {
401402
const siteId = options.siteId || null
402-
const slasEndpointsRequiringAccessToken = options.slasEndpointsRequiringAccessToken
403-
configureProxyConfigs(
404-
options.appHostname,
405-
options.protocol,
406-
siteId,
407-
slasEndpointsRequiringAccessToken
408-
)
403+
configureProxyConfigs(options.appHostname, options.protocol, siteId)
409404
},
410405

411406
/**
@@ -982,6 +977,41 @@ export const RemoteServerFactory = {
982977
// SLAS logout (/oauth2/logout), use the Authorization header for a different
983978
// purpose so we don't want to overwrite the header for those calls.
984979
proxyRequest.setHeader('Authorization', `Basic ${encodedSlasCredentials}`)
980+
} else if (
981+
process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES === 'false' &&
982+
incomingRequest.path?.match(options.slasEndpointsRequiringAccessToken)
983+
) {
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+
}
9851015
}
9861016

9871017
// Allow users to apply additional custom modifications to the proxy request

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -483,11 +483,15 @@ describe('HttpOnly session cookies', () => {
483483
}
484484
})
485485

486-
test('skips non-token endpoints like logout', async () => {
486+
test('injects Bearer token and refresh token from HttpOnly cookies for logout endpoint', async () => {
487487
process.env.MRT_DISABLE_HTTPONLY_SESSION_COOKIES = 'false'
488488

489+
let capturedAuthHeader
490+
let capturedRefreshToken
489491
const mockSlasServer = mockExpress()
490492
mockSlasServer.post('/shopper/auth/v1/oauth2/logout', (req, res) => {
493+
capturedAuthHeader = req.headers.authorization
494+
capturedRefreshToken = req.query.refresh_token
491495
res.status(200).json({success: true})
492496
})
493497

@@ -517,12 +521,17 @@ describe('HttpOnly session cookies', () => {
517521

518522
RemoteServerFactory._setupSlasPrivateClientProxy(app, options)
519523

520-
const response = await request(app).post(
521-
'/mobify/slas/private/shopper/auth/v1/oauth2/logout'
522-
)
524+
const response = await request(app)
525+
.post('/mobify/slas/private/shopper/auth/v1/oauth2/logout')
526+
.set(
527+
'Cookie',
528+
'cc-at_testsite=mock-access-token; cc-nx_testsite=mock-refresh-token'
529+
)
523530

524531
expect(response.status).toBe(200)
525532
expect(response.body.success).toBe(true)
533+
expect(capturedAuthHeader).toBe('Bearer mock-access-token')
534+
expect(capturedRefreshToken).toBe('mock-refresh-token')
526535
expect(response.headers['set-cookie']).toBeUndefined()
527536
} finally {
528537
mockSlasServerInstance.close()

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,23 @@ export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res,
197197
expires: refreshExpires
198198
})
199199
)
200+
201+
// Delete the opposite refresh token cookie to mirror client-side behavior:
202+
// Login (guest → registered): delete guest cookie cc-nx-g
203+
// Logout (registered → guest): delete registered cookie cc-nx
204+
const staleCookieName = isGuest ? `cc-nx_${site}` : `cc-nx-g_${site}`
205+
res.append(
206+
SET_COOKIE,
207+
cookieAsString({
208+
name: staleCookieName,
209+
value: '',
210+
path: '/',
211+
secure: true,
212+
sameSite: 'lax',
213+
httpOnly: true,
214+
expires: new Date(0)
215+
})
216+
)
200217
}
201218

202219
// Strip token fields from body so they are not exposed to the client

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,12 @@ describe('applyHttpOnlySessionCookies', () => {
204204
expect(uidoCookie.value).toBe('ecom')
205205
expect(uidoCookie.httpOnly).toBeUndefined()
206206

207-
// Should NOT have registered refresh cookie
208-
expect(res.cookies.find((c) => c.startsWith('cc-nx_testsite='))).toBeUndefined()
207+
// Registered refresh cookie should be expired (deleted)
208+
const staleRegisteredCookie = parseCookie(
209+
res.cookies.find((c) => c.startsWith('cc-nx_testsite='))
210+
)
211+
expect(staleRegisteredCookie.value).toBe('')
212+
expect(staleRegisteredCookie.expires).toEqual(new Date(0))
209213

210214
// Tokens stripped from body, other fields preserved
211215
const body = JSON.parse(result.toString('utf8'))
@@ -249,8 +253,12 @@ describe('applyHttpOnlySessionCookies', () => {
249253
const uidoCookie = parseCookie(res.cookies.find((c) => c.includes('uido_testsite=')))
250254
expect(uidoCookie.value).toBe('ecom')
251255

252-
// Should NOT have guest refresh cookie
253-
expect(res.cookies.find((c) => c.includes('cc-nx-g_testsite='))).toBeUndefined()
256+
// Guest refresh cookie should be expired (deleted)
257+
const staleGuestCookie = parseCookie(
258+
res.cookies.find((c) => c.startsWith('cc-nx-g_testsite='))
259+
)
260+
expect(staleGuestCookie.value).toBe('')
261+
expect(staleGuestCookie.expires).toEqual(new Date(0))
254262

255263
// No dnt cookie when dnt absent from JWT
256264
expect(res.cookies.find((c) => c.includes('cc-at-dnt_testsite'))).toBeUndefined()

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

Lines changed: 8 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,7 @@ describe('applyScapiAuthHeaders', () => {
113113
incomingRequest,
114114
caching: false,
115115
siteId: 'RefArch',
116-
targetHost: 'abc-001.api.commercecloud.salesforce.com',
117-
slasEndpointsRequiringAccessToken: /\/oauth2\/logout/
116+
targetHost: 'abc-001.api.commercecloud.salesforce.com'
118117
})
119118

120119
expect(proxyRequest.setHeader).toHaveBeenCalledWith(
@@ -123,7 +122,7 @@ describe('applyScapiAuthHeaders', () => {
123122
)
124123
})
125124

126-
it('applies Bearer token for SLAS logout endpoint', () => {
125+
it('skips all SLAS auth endpoints (handled by SLAS private proxy)', () => {
127126
utils.isScapiDomain.mockReturnValue(true)
128127
cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'})
129128

@@ -140,38 +139,10 @@ describe('applyScapiAuthHeaders', () => {
140139
incomingRequest,
141140
caching: false,
142141
siteId: 'RefArch',
143-
targetHost: 'abc-001.api.commercecloud.salesforce.com',
144-
slasEndpointsRequiringAccessToken: /\/oauth2\/logout/
142+
targetHost: 'abc-001.api.commercecloud.salesforce.com'
145143
})
146144

147-
expect(proxyRequest.setHeader).toHaveBeenCalledWith(
148-
'authorization',
149-
'Bearer test-access-token'
150-
)
151-
})
152-
153-
it('does not apply Bearer token for SLAS token endpoint', () => {
154-
utils.isScapiDomain.mockReturnValue(true)
155-
cookie.parse.mockReturnValue({'cc-at_RefArch': 'test-access-token'})
156-
157-
const proxyRequest = {
158-
setHeader: jest.fn()
159-
}
160-
const incomingRequest = {
161-
url: '/shopper/auth/v1/oauth2/token',
162-
headers: {cookie: 'cc-at_RefArch=test-access-token'}
163-
}
164-
165-
applyScapiAuthHeaders({
166-
proxyRequest,
167-
incomingRequest,
168-
caching: false,
169-
siteId: 'RefArch',
170-
targetHost: 'abc-001.api.commercecloud.salesforce.com',
171-
slasEndpointsRequiringAccessToken: /\/oauth2\/logout/
172-
})
173-
174-
// Should not set authorization header for token endpoint (uses Basic Auth)
145+
// SLAS auth endpoints are handled by the SLAS private client proxy
175146
expect(proxyRequest.setHeader).not.toHaveBeenCalled()
176147
})
177148

@@ -192,8 +163,7 @@ describe('applyScapiAuthHeaders', () => {
192163
incomingRequest,
193164
caching: true,
194165
siteId: 'RefArch',
195-
targetHost: 'abc-001.api.commercecloud.salesforce.com',
196-
slasEndpointsRequiringAccessToken: /\/oauth2\/logout/
166+
targetHost: 'abc-001.api.commercecloud.salesforce.com'
197167
})
198168

199169
// Caching proxies don't use auth
@@ -217,8 +187,7 @@ describe('applyScapiAuthHeaders', () => {
217187
incomingRequest,
218188
caching: false,
219189
siteId: null,
220-
targetHost: 'abc-001.api.commercecloud.salesforce.com',
221-
slasEndpointsRequiringAccessToken: /\/oauth2\/logout/
190+
targetHost: 'abc-001.api.commercecloud.salesforce.com'
222191
})
223192

224193
expect(proxyRequest.setHeader).not.toHaveBeenCalled()
@@ -241,8 +210,7 @@ describe('applyScapiAuthHeaders', () => {
241210
incomingRequest,
242211
caching: false,
243212
siteId: 'RefArch',
244-
targetHost: 'external-api.example.com',
245-
slasEndpointsRequiringAccessToken: /\/oauth2\/logout/
213+
targetHost: 'external-api.example.com'
246214
})
247215

248216
expect(proxyRequest.setHeader).not.toHaveBeenCalled()
@@ -265,8 +233,7 @@ describe('applyScapiAuthHeaders', () => {
265233
incomingRequest,
266234
caching: false,
267235
siteId: 'RefArch',
268-
targetHost: 'abc-001.api.commercecloud.salesforce.com',
269-
slasEndpointsRequiringAccessToken: /\/oauth2\/logout/
236+
targetHost: 'abc-001.api.commercecloud.salesforce.com'
270237
})
271238

272239
expect(proxyRequest.setHeader).not.toHaveBeenCalled()

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

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {processExpressResponse} from './process-express-response'
1212
import {isRemote, localDevLog, verboseProxyLogging, isScapiDomain} from './utils'
1313
import logger from '../logger-instance'
1414
import {getEnvBasePath} from '../ssr-namespace-paths'
15-
import {SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN} from '../../ssr/server/constants'
1615

1716
export const ALLOWED_CACHING_PROXY_REQUEST_METHODS = ['HEAD', 'GET', 'OPTIONS']
1817

@@ -37,8 +36,8 @@ const generalProxyPathRE = /^\/mobify\/proxy\/([^/]+)(\/.*)$/
3736
* 1. Caching proxies never use auth (skip)
3837
* 2. siteId must be provided (skip if not)
3938
* 3. Target must be SCAPI domain (skip if not)
40-
* 4. For SLAS auth endpoints (/shopper/auth/*): Only apply if they match the regex
41-
* - Most use Basic Auth (client credentials), but some like /oauth2/logout need Bearer token
39+
* 4. SLAS auth endpoints (/shopper/auth/*) are skipped — Bearer injection for SLAS
40+
* is handled by the SLAS private client proxy in build-remote-server.js
4241
* 5. For non-SLAS auth endpoints (e.g., /shopper/products, /shopper/baskets): Always apply Bearer token
4342
*
4443
* @private
@@ -48,32 +47,24 @@ const generalProxyPathRE = /^\/mobify\/proxy\/([^/]+)(\/.*)$/
4847
* @param caching {Boolean} true for a caching proxy, false for a standard proxy
4948
* @param siteId {String} the site ID for the current request
5049
* @param targetHost {String} the target hostname (host+port)
51-
* @param slasEndpointsRequiringAccessToken {RegExp} regex for SLAS auth endpoints that need Bearer token
5250
*/
5351
export const applyScapiAuthHeaders = ({
5452
proxyRequest,
5553
incomingRequest,
5654
caching,
5755
siteId,
58-
targetHost,
59-
slasEndpointsRequiringAccessToken
56+
targetHost
6057
}) => {
6158
const url = incomingRequest.url
6259

6360
// Skip if: caching proxy, no siteId, not SCAPI domain, or no URL
6461
if (caching || !siteId || !isScapiDomain(targetHost) || !url) {
6562
return
6663
}
64+
// SLAS auth endpoints are handled by the SLAS private client proxy
6765
if (url.startsWith('/shopper/auth/')) {
68-
// For SLAS auth endpoints, only apply if they match the configured regex
69-
// Most SLAS endpoints use Basic Auth, only specific ones like /oauth2/logout need Bearer token
70-
if (!slasEndpointsRequiringAccessToken || !url.match(slasEndpointsRequiringAccessToken)) {
71-
return
72-
}
66+
return
7367
}
74-
// If we reach here, either:
75-
// 1. It's a SLAS auth endpoint that matched the regex, OR
76-
// 2. It's a non-SLAS auth endpoint (which always requires Bearer token)
7768

7869
// Get access token from HttpOnly cookie
7970
const cookieHeader = incomingRequest.headers.cookie
@@ -185,7 +176,6 @@ export const applyProxyRequestHeaders = ({
185176
* @param caching {Boolean} true for a caching proxy, false for a
186177
* standard proxy.
187178
* @param siteId {String} the site ID for the current request
188-
* @param slasEndpointsRequiringAccessToken {RegExp} regex for SLAS auth endpoints that require Bearer token
189179
* @returns {middleware} function to pass to expressApp.use()
190180
*/
191181
export const configureProxy = ({
@@ -195,8 +185,7 @@ export const configureProxy = ({
195185
targetHost,
196186
appProtocol = /* istanbul ignore next */ 'https',
197187
caching,
198-
siteId = null,
199-
slasEndpointsRequiringAccessToken = SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN
188+
siteId = null
200189
}) => {
201190
// This configuration must match the behaviour of the proxying
202191
// in CloudFront.
@@ -279,8 +268,7 @@ export const configureProxy = ({
279268
incomingRequest,
280269
caching,
281270
siteId,
282-
targetHost,
283-
slasEndpointsRequiringAccessToken
271+
targetHost
284272
})
285273
},
286274

@@ -374,15 +362,9 @@ export const configureProxy = ({
374362
* @param {String} appProtocol {String} the protocol to use to make requests to
375363
* the origin ('http' or 'https', defaults to 'https')
376364
* @param {String} siteId - the site ID for the current request
377-
* @param {RegExp} slasEndpointsRequiringAccessToken - regex for SLAS auth endpoints that require Bearer token
378365
* @private
379366
*/
380-
export const configureProxyConfigs = (
381-
appHostname,
382-
appProtocol,
383-
siteId = null,
384-
slasEndpointsRequiringAccessToken = SLAS_ENDPOINTS_REQUIRING_ACCESS_TOKEN
385-
) => {
367+
export const configureProxyConfigs = (appHostname, appProtocol, siteId = null) => {
386368
localDevLog('')
387369
proxyConfigs.forEach((config) => {
388370
localDevLog(
@@ -395,8 +377,7 @@ export const configureProxyConfigs = (
395377
appProtocol,
396378
appHostname,
397379
caching: false,
398-
siteId,
399-
slasEndpointsRequiringAccessToken
380+
siteId
400381
})
401382
config.cachingProxy = configureProxy({
402383
proxyPath: config.cachingPath,

0 commit comments

Comments
 (0)