Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
1 change: 1 addition & 0 deletions packages/pwa-kit-create-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/pwa-kit-runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
109 changes: 55 additions & 54 deletions packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -94,6 +94,50 @@ 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 injectLogoutTokens = (proxyRequest, incomingRequest) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

minor refactor - moved to a separate function

Copy link
Contributor

Choose a reason for hiding this comment

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

Should this function be moved into process-token-response?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it is not really related to processing the response. Other option is to rename process-token-response to a more generic one - httponly-cookie-utils and then move this function. I will take another look and see if it make sense to rename

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will leave it as is for now and consider rename/refactor as we make more changes

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: 'injectLogoutTokens'}
)
return
}

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
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(
`Refresh token cookie (cc-nx_${site}) not found for ${incomingRequest.path}. The logout request may fail.`,
{namespace: 'injectLogoutTokens'}
)
}
}

/**
* Environment variables that must be set for the Express app to run remotely.
*
Expand Down Expand Up @@ -170,11 +214,10 @@ 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,
// A regex for matching the SLAS logout endpoint. When HttpOnly session cookies are
// enabled, the proxy injects the Bearer token and refresh token from HttpOnly cookies
// for this endpoint. Users can override this in ssr.js.
slasLogoutEndpoint: SLAS_LOGOUT_ENDPOINT,

// 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
Expand Down Expand Up @@ -233,16 +276,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
},

Expand Down Expand Up @@ -399,8 +432,7 @@ export const RemoteServerFactory = {
* @private
*/
_configureProxyConfigs(options) {
const siteId = options.siteId || null
configureProxyConfigs(options.appHostname, options.protocol, siteId)
configureProxyConfigs(options.appHostname, options.protocol)
},

/**
Expand Down Expand Up @@ -979,39 +1011,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(options.slasLogoutEndpoint)
) {
// 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'
}
)
}
}
}
injectLogoutTokens(proxyRequest, incomingRequest)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Can we make this func a bit clearer: injectTokenToLogoutEndpoint? and it would be helpful to explain why

// SLAS log out endpoint requires token and refresh token in the body, the other SLAS does not need to pass token in the body
injectTokenToLogoutEndpoint(...)

}

// Allow users to apply additional custom modifications to the proxy request
Expand Down Expand Up @@ -1499,10 +1501,9 @@ 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 {RegExp} [options.slasLogoutEndpoint] - A regex pattern to match the SLAS logout endpoint.
* When HttpOnly session cookies are enabled, the proxy injects the Bearer token and refresh token
* from HttpOnly cookies for this endpoint. 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
7 changes: 4 additions & 3 deletions packages/pwa-kit-runtime/src/ssr/server/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,11 @@ function getTokenClaims(accessToken) {
* @private
*/
export function applyHttpOnlySessionCookies(responseBuffer, proxyRes, req, res, options) {
const siteId = options.mobify?.app?.commerceAPI?.parameters?.siteId
const siteId = req.headers?.['x-site-id']
if (!siteId || typeof siteId !== 'string' || siteId.trim() === '') {
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.'
)
}

Expand Down
Loading
Loading