Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@W-17386338 - Secure SSR Endpoints by Verifying SLAS Callback Requests #2180

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
44 changes: 30 additions & 14 deletions packages/template-retail-react-app/app/ssr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'))
Expand Down
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}").`)
}
Comment on lines +28 to +30
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If the configured tenantId and the JWT tenantId do not match, an error is thrown.

The short code is not provided as part of the JWT, so we can only rely on the configured value in the config file.

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]
Comment on lines +36 to +39
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Extracting the tenantId from the JWT here by reading the iss claim

try {
const jwks = createRemoteJWKSet(tenantId)
const {payload} = await jwtVerify(token, jwks, {})
Copy link
Collaborator

@adamraya adamraya Jan 22, 2025

Choose a reason for hiding this comment

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

Are we only relying on jwtVerify() to validate the token? Should we also check headers, such as kid, alg, or jku?

I'm not an expert in this area, but I recommend reaching out to a SLAS expert who can guide you on what is the CC standard to consider a token valid.

Another question for the SLAS expert is whether it is safe to share these validation logic in a public repo.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I will ask Blair for his input on this!

Copy link
Collaborator Author

@yunakim714 yunakim714 Jan 22, 2025

Choose a reason for hiding this comment

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

@adamraya I believe it is sufficient to call jwtVerify and have the validation logic in this repo. Unless the PWA team has preferences on where this logic is placed, security wise I believe it is not a risk.

From Blair: "There is no need to validate the header portion of the JWT. The kid is the most important header claim as that is the KeyID of the public key to validate the body signature. Calling jwtVerify should be fine.
I think it would be okay to add the validation of the JWT signature to the repo. After all it is using the Public keys and not exposing the private key or how the JWT was signed."

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see. I mention this because we usually have an additional layer of validation that checks those claims to ensure the token context is valid, which jwtVerify does not validate. But let's continue with what the experts say.

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
Expand Up @@ -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,
Expand Down
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
Expand Up @@ -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",
Expand Down
Loading