Skip to content

Commit

Permalink
@W-17386338 - Secure SSR Endpoints by Verifying SLAS Callback Requests (
Browse files Browse the repository at this point in the history
#2180)

Validate the `x-slas-callback-token` header, which is a JWT provided by SLAS during passwordless login and reset password POST callbacks. Unauthorized requests are rejected with a 401 Unauthorized error.
yunakim714 authored Jan 23, 2025

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 1b9aeb5 commit b70a97c
Showing 7 changed files with 264 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -190,10 +190,7 @@ describe('updating password', function () {
id_token: 'testIdToken'
})
)
),
rest.post('*/baskets/actions/merge', (req, res, ctx) => {
return res(ctx.delay(0), ctx.json(mockMergedBasket))
})
)
)
})
test('Password update form is rendered correctly', async () => {
44 changes: 30 additions & 14 deletions packages/template-retail-react-app/app/ssr.js
Original file line number Diff line number Diff line change
@@ -28,6 +28,10 @@ import {
PASSWORDLESS_LOGIN_LANDING_PATH,
RESET_PASSWORD_LANDING_PATH
} from '@salesforce/retail-react-app/app/constants'
import {
validateSlasCallbackToken,
jwksCaching
} from '@salesforce/retail-react-app/app/utils/jwt-utils'

const config = getConfig()

@@ -98,6 +102,8 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate) {
}

const {handler} = runtime.createHandler(options, (app) => {
app.use(express.json()) // To parse JSON payloads
app.use(express.urlencoded({extended: true}))
// Set default HTTP security headers required by PWA Kit
app.use(defaultPwaKitSecurityHeaders)
// Set custom HTTP security headers
@@ -131,30 +137,40 @@ const {handler} = runtime.createHandler(options, (app) => {
res.send()
})

app.get('/:shortCode/:tenantId/oauth2/jwks', (req, res) => {
jwksCaching(req, res, {shortCode: req.params.shortCode, tenantId: req.params.tenantId})
})

// Handles the passwordless login callback route. SLAS makes a POST request to this
// endpoint sending the email address and passwordless token. Then this endpoint calls
// the sendMagicLinkEmail function to send an email with the passwordless login magic link.
// https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-passwordless-login.html#receive-the-callback
app.post(passwordlessLoginCallback, express.json(), (req, res) => {
sendMagicLinkEmail(
req,
res,
PASSWORDLESS_LOGIN_LANDING_PATH,
process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE
)
app.post(passwordlessLoginCallback, (req, res) => {
const slasCallbackToken = req.headers['x-slas-callback-token']
validateSlasCallbackToken(slasCallbackToken).then(() => {
sendMagicLinkEmail(
req,
res,
PASSWORDLESS_LOGIN_LANDING_PATH,
process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE
)
})
})

// Handles the reset password callback route. SLAS makes a POST request to this
// endpoint sending the email address and reset password token. Then this endpoint calls
// the sendMagicLinkEmail function to send an email with the reset password magic link.
// https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-password-reset.html#slas-password-reset-flow
app.post(resetPasswordCallback, express.json(), (req, res) => {
sendMagicLinkEmail(
req,
res,
RESET_PASSWORD_LANDING_PATH,
process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE
)
app.post(resetPasswordCallback, (req, res) => {
const slasCallbackToken = req.headers['x-slas-callback-token']
validateSlasCallbackToken(slasCallbackToken).then(() => {
sendMagicLinkEmail(
req,
res,
RESET_PASSWORD_LANDING_PATH,
process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE
)
})
})

app.get('/robots.txt', runtime.serveStaticFile('static/robots.txt'))
86 changes: 86 additions & 0 deletions packages/template-retail-react-app/app/utils/jwt-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2021, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'

const CLAIM = {
ISSUER: 'iss'
}

const DELIMITER = {
ISSUER: '/'
}

const throwSlasTokenValidationError = (message, code) => {
throw new Error(`SLAS Token Validation Error: ${message}`, code)
}

export const createRemoteJWKSet = (tenantId) => {
const appOrigin = getAppOrigin()
const {app: appConfig} = getConfig()
const shortCode = appConfig.commerceAPI.parameters.shortCode
const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '')
if (tenantId !== configTenantId) {
throw new Error(`The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`)
}
const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks`
return joseCreateRemoteJWKSet(new URL(JWKS_URI))
}

export const validateSlasCallbackToken = async (token) => {
const payload = decodeJwt(token)
const subClaim = payload[CLAIM.ISSUER]
const tokens = subClaim.split(DELIMITER.ISSUER)
const tenantId = tokens[2]
try {
const jwks = createRemoteJWKSet(tenantId)
const {payload} = await jwtVerify(token, jwks, {})
return payload
} catch (error) {
throwSlasTokenValidationError(error.message, 401)
}
}

const tenantIdRegExp = /^[a-zA-Z]{4}_([0-9]{3}|s[0-9]{2}|stg|dev|prd)$/
const shortCodeRegExp = /^[a-zA-Z0-9-]+$/

/**
* Handles JWKS (JSON Web Key Set) caching the JWKS response for 2 weeks.
*
* @param {object} req Express request object.
* @param {object} res Express response object.
* @param {object} options Options for fetching B2C Commerce API JWKS.
* @param {string} options.shortCode - The Short Code assigned to the realm.
* @param {string} options.tenantId - The Tenant ID for the ECOM instance.
* @returns {Promise<*>} Promise with the JWKS data.
*/
export async function jwksCaching(req, res, options) {
const {shortCode, tenantId} = options

const isValidRequest = tenantIdRegExp.test(tenantId) && shortCodeRegExp.test(shortCode)
if (!isValidRequest)
return res
.status(400)
.json({error: 'Bad request parameters: Tenant ID or short code is invalid.'})
try {
const JWKS_URI = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/f_ecom_${tenantId}/oauth2/jwks`
const response = await fetch(JWKS_URI)

if (!response.ok) {
throw new Error('Request failed with status: ' + response.status)
}

// JWKS rotate every 30 days. For now, cache response for 14 days so that
// fetches only need to happen twice a month
res.set('Cache-Control', 'public, max-age=1209600')

return res.json(await response.json())
} catch (error) {
res.status(400).json({error: `Error while fetching data: ${error.message}`})
}
}
134 changes: 134 additions & 0 deletions packages/template-retail-react-app/app/utils/jwt-utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
import {
createRemoteJWKSet,
validateSlasCallbackToken
} from '@salesforce/retail-react-app/app/utils/jwt-utils'
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'

const MOCK_JWKS = {
keys: [
{
kty: 'EC',
crv: 'P-256',
use: 'sig',
kid: '8edb82b1-f6d5-49c1-bab2-c0d152ee3d0b',
x: 'i8e53csluQiqwP6Af8KsKgnUceXUE8_goFcvLuSzG3I',
y: 'yIH500tLKJtPhIl7MlMBOGvxQ_3U-VcrrXusr8bVr_0'
},
{
kty: 'EC',
crv: 'P-256',
use: 'sig',
kid: 'da9effc5-58cb-4a9c-9c9c-2919fb7d5e5e',
x: '_tAU1QSvcEkslcrbNBwx5V20-sN87z0zR7gcSdBETDQ',
y: 'ZJ7bgy7WrwJUGUtzcqm3MNyIfawI8F7fVawu5UwsN8E'
},
{
kty: 'EC',
crv: 'P-256',
use: 'sig',
kid: '5ccbbc6e-b234-4508-90f3-3b9b17efec16',
x: '9ULO2Atj5XToeWWAT6e6OhSHQftta4A3-djgOzcg4-Q',
y: 'JNuQSLMhakhLWN-c6Qi99tA5w-D7IFKf_apxVbVsK-g'
}
]
}

jest.mock('@salesforce/pwa-kit-react-sdk/utils/url', () => ({
getAppOrigin: jest.fn()
}))

jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({
getConfig: jest.fn()
}))

jest.mock('jose', () => ({
createRemoteJWKSet: jest.fn(),
jwtVerify: jest.fn(),
decodeJwt: jest.fn()
}))

describe('createRemoteJWKSet', () => {
afterEach(() => {
jest.restoreAllMocks()
})

it('constructs the correct JWKS URI and call joseCreateRemoteJWKSet', () => {
const mockTenantId = 'aaaa_001'
const mockAppOrigin = 'https://test-storefront.com'
getAppOrigin.mockReturnValue(mockAppOrigin)
getConfig.mockReturnValue({
app: {
commerceAPI: {
parameters: {
shortCode: 'abc123',
organizationId: 'f_ecom_aaaa_001'
}
}
}
})
joseCreateRemoteJWKSet.mockReturnValue('mockJWKSet')

const expectedJWKS_URI = new URL(`${mockAppOrigin}/abc123/aaaa_001/oauth2/jwks`)

const res = createRemoteJWKSet(mockTenantId)

expect(getAppOrigin).toHaveBeenCalled()
expect(getConfig).toHaveBeenCalled()
expect(joseCreateRemoteJWKSet).toHaveBeenCalledWith(expectedJWKS_URI)
expect(res).toBe('mockJWKSet')
})
})

describe('validateSlasCallbackToken', () => {
beforeEach(() => {
jest.resetAllMocks()
const mockAppOrigin = 'https://test-storefront.com'
getAppOrigin.mockReturnValue(mockAppOrigin)
getConfig.mockReturnValue({
app: {
commerceAPI: {
parameters: {
shortCode: 'abc123',
organizationId: 'f_ecom_aaaa_001'
}
}
}
})
joseCreateRemoteJWKSet.mockReturnValue(MOCK_JWKS)
})

it('returns payload when callback token is valid', async () => {
decodeJwt.mockReturnValue({iss: 'slas/dev/aaaa_001'})
const mockPayload = {sub: '123', role: 'admin'}
jwtVerify.mockResolvedValue({payload: mockPayload})

const res = await validateSlasCallbackToken('mock.slas.token')

expect(jwtVerify).toHaveBeenCalledWith('mock.slas.token', MOCK_JWKS, {})
expect(res).toEqual(mockPayload)
})

it('throws validation error when the token is invalid', async () => {
decodeJwt.mockReturnValue({iss: 'slas/dev/aaaa_001'})
const mockError = new Error('Invalid token')
jwtVerify.mockRejectedValue(mockError)

await expect(validateSlasCallbackToken('mock.slas.token')).rejects.toThrow(
mockError.message
)
expect(jwtVerify).toHaveBeenCalledWith('mock.slas.token', MOCK_JWKS, {})
})

it('throws mismatch error when the config tenantId does not match the jwt tenantId', async () => {
decodeJwt.mockReturnValue({iss: 'slas/dev/zzrf_001'})
await expect(validateSlasCallbackToken('mock.slas.token')).rejects.toThrow()
})
})
3 changes: 2 additions & 1 deletion packages/template-retail-react-app/config/default.js
Original file line number Diff line number Diff line change
@@ -18,7 +18,8 @@ module.exports = {
login: {
passwordless: {
enabled: false,
callbackURI: process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback'
callbackURI:
process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback'
},
social: {
enabled: false,
10 changes: 10 additions & 0 deletions packages/template-retail-react-app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/template-retail-react-app/package.json
Original file line number Diff line number Diff line change
@@ -65,6 +65,7 @@
"full-icu": "^1.5.0",
"helmet": "^4.6.0",
"jest-fetch-mock": "^2.1.2",
"jose": "^4.14.4",
"js-cookie": "^3.0.1",
"jsonwebtoken": "^9.0.0",
"jwt-decode": "^4.0.0",

0 comments on commit b70a97c

Please sign in to comment.