Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
48 changes: 30 additions & 18 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 @@ -232,16 +233,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 @@ -398,14 +389,7 @@ export const RemoteServerFactory = {
* @private
*/
_configureProxyConfigs(options) {
const siteId = options.siteId || null
const slasEndpointsRequiringAccessToken = options.slasEndpointsRequiringAccessToken
configureProxyConfigs(
options.appHostname,
options.protocol,
siteId,
slasEndpointsRequiringAccessToken
)
configureProxyConfigs(options.appHostname, options.protocol)
},

/**
Expand Down Expand Up @@ -982,6 +966,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 = incomingRequest.headers['x-site-id']
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,18 +521,77 @@ 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')
.set('x-site-id', 'testsite')

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()
}
})

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 @@ -571,9 +634,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 @@ -627,9 +690,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 @@ -681,9 +744,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
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,22 @@ function makeJWT(payload) {
return `${header}.${payloadPart}.sig`
}

function makeOptions(siteId = 'testsite') {
function makeOptions() {
return {
mobify: {
app: {
commerceAPI: {
parameters: {siteId}
parameters: {}
}
}
}
}
}

function makeReq(siteId = 'testsite') {
return {headers: {'x-site-id': siteId}}
}

function makeRes() {
const cookies = []
return {
Expand Down Expand Up @@ -127,26 +131,28 @@ describe('applyHttpOnlySessionCookies', () => {
jest.clearAllMocks()
})

test('throws when siteId is missing', () => {
test('throws when x-site-id header is missing', () => {
const res = makeRes()
const buf = makeResponseBuffer({access_token: 'x'})
expect(() => applyHttpOnlySessionCookies(buf, {}, {}, res, {mobify: {}})).toThrow(
const req = {headers: {}}
expect(() => applyHttpOnlySessionCookies(buf, {}, req, res, makeOptions())).toThrow(
/siteId is missing/
)
})

test('throws when siteId is empty string', () => {
test('throws when x-site-id header is empty string', () => {
const res = makeRes()
const buf = makeResponseBuffer({access_token: 'x'})
expect(() => applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions(' '))).toThrow(
const req = makeReq(' ')
expect(() => applyHttpOnlySessionCookies(buf, {}, req, res, makeOptions())).toThrow(
/siteId is missing/
)
})

test('returns buffer unchanged for non-JSON response', () => {
const res = makeRes()
const buf = Buffer.from('not json', 'utf8')
const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions())
const result = applyHttpOnlySessionCookies(buf, {}, makeReq(), res, makeOptions())
expect(result).toBe(buf)
expect(res.append).not.toHaveBeenCalled()
})
Expand All @@ -166,7 +172,7 @@ describe('applyHttpOnlySessionCookies', () => {
expires_in: 1800,
customer_id: 'cust123'
})
const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions())
const result = applyHttpOnlySessionCookies(buf, {}, makeReq(), res, makeOptions())

// cc-at: access token (HttpOnly)
const atCookie = parseCookie(res.cookies.find((c) => c.includes('cc-at_testsite=')))
Expand Down Expand Up @@ -228,7 +234,7 @@ describe('applyHttpOnlySessionCookies', () => {
refresh_token: 'refresh-value',
expires_in: 1800
})
const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions())
const result = applyHttpOnlySessionCookies(buf, {}, makeReq(), res, makeOptions())

// cc-at (HttpOnly)
const atCookie = parseCookie(res.cookies.find((c) => c.includes('cc-at_testsite=')))
Expand Down Expand Up @@ -269,15 +275,15 @@ describe('applyHttpOnlySessionCookies', () => {
refresh_token: 'refresh-value',
expires_in: 1800
})
applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions())
applyHttpOnlySessionCookies(buf, {}, makeReq(), res, makeOptions())

expect(res.cookies.find((c) => c.includes('uido_testsite'))).toBeUndefined()
})

test('throws when access token JWT is invalid', () => {
const res = makeRes()
const buf = makeResponseBuffer({access_token: 'not-a-jwt', expires_in: 1800})
expect(() => applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions())).toThrow(
expect(() => applyHttpOnlySessionCookies(buf, {}, makeReq(), res, makeOptions())).toThrow(
/Failed to decode access token JWT/
)
})
Expand All @@ -286,7 +292,7 @@ describe('applyHttpOnlySessionCookies', () => {
const res = makeRes()
const accessToken = makeJWT({iat: 5000, exp: 6800, isb: 'uido:ecom::upn:Guest'})
const buf = makeResponseBuffer({access_token: accessToken})
applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions())
applyHttpOnlySessionCookies(buf, {}, makeReq(), res, makeOptions())

const expCookie = res.cookies.find((c) => c.includes('cc-at-expires_testsite='))
const parsed = parseCookie(expCookie)
Expand All @@ -296,22 +302,32 @@ describe('applyHttpOnlySessionCookies', () => {
test('handles response with no tokens (no cookies set, body returned stripped)', () => {
const res = makeRes()
const buf = makeResponseBuffer({expires_in: 1800, other_field: 'value'})
const result = applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions())
const result = applyHttpOnlySessionCookies(buf, {}, makeReq(), res, makeOptions())
const body = JSON.parse(result.toString('utf8'))

expect(res.cookies).toHaveLength(0)
expect(body.other_field).toBe('value')
})

test('trims siteId and uses trimmed value in cookie names', () => {
test('trims x-site-id and uses trimmed value in cookie names', () => {
const res = makeRes()
const accessToken = makeJWT({iat: 1000, isb: 'uido:ecom::upn:Guest'})
const buf = makeResponseBuffer({access_token: accessToken, expires_in: 1800})
applyHttpOnlySessionCookies(buf, {}, {}, res, makeOptions(' mysite '))
applyHttpOnlySessionCookies(buf, {}, makeReq(' mysite '), res, makeOptions())

const atCookie = res.cookies.find((c) => c.includes('cc-at_mysite='))
expect(atCookie).toBeDefined()
// No leading/trailing spaces in cookie name
expect(res.cookies.find((c) => c.includes('cc-at_ mysite'))).toBeUndefined()
})

test('uses x-site-id header to resolve correct cookie names', () => {
const res = makeRes()
const accessToken = makeJWT({iat: 1000, exp: 2800, isb: 'uido:ecom::upn:Guest'})
const buf = makeResponseBuffer({access_token: accessToken, expires_in: 1800})
applyHttpOnlySessionCookies(buf, {}, makeReq('othersite'), res, makeOptions())

const atCookie = res.cookies.find((c) => c.includes('cc-at_othersite='))
expect(atCookie).toBeDefined()
})
})
Loading
Loading