Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -399,13 +400,7 @@ export const RemoteServerFactory = {
*/
_configureProxyConfigs(options) {
const siteId = options.siteId || null
const slasEndpointsRequiringAccessToken = options.slasEndpointsRequiringAccessToken
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved slasEndpointsRequiringAccessToken to slas private proxy because slas calls do not go through the scapi proxy (/mobify/proxy)

configureProxyConfigs(
options.appHostname,
options.protocol,
siteId,
slasEndpointsRequiringAccessToken
)
configureProxyConfigs(options.appHostname, options.protocol, siteId)
},

/**
Expand Down Expand Up @@ -982,6 +977,34 @@ 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 (
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
// Try registered user cookie first, then guest
const refreshToken =
cookies[`cc-nx_${site}`] || cookies[`cc-nx-g_${site}`]
if (refreshToken) {
const url = new URL(proxyRequest.path, 'http://localhost')
url.searchParams.set('refresh_token', refreshToken)
proxyRequest.path = url.pathname + url.search
}
}
}
}

// Allow users to apply additional custom modifications to the proxy request
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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})
})

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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'})

Expand All @@ -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()
})

Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand All @@ -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
Expand All @@ -48,32 +47,24 @@ 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

// Skip if: caching proxy, no siteId, not SCAPI domain, or no URL
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
Expand Down Expand Up @@ -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 = ({
Expand All @@ -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.
Expand Down Expand Up @@ -279,8 +268,7 @@ export const configureProxy = ({
incomingRequest,
caching,
siteId,
targetHost,
slasEndpointsRequiringAccessToken
targetHost
})
},

Expand Down Expand Up @@ -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(
Expand All @@ -395,8 +377,7 @@ export const configureProxyConfigs = (
appProtocol,
appHostname,
caching: false,
siteId,
slasEndpointsRequiringAccessToken
siteId
})
config.cachingProxy = configureProxy({
proxyPath: config.cachingPath,
Expand Down
Loading