From b70a97c72b320ae24d9b61cc3d187308ddfe54d5 Mon Sep 17 00:00:00 2001 From: Yuna Kim <84923642+yunakim714@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:18:30 -0500 Subject: [PATCH] @W-17386338 - Secure SSR Endpoints by Verifying SLAS Callback Requests (#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. --- .../app/pages/account/index.test.js | 5 +- packages/template-retail-react-app/app/ssr.js | 44 ++++-- .../app/utils/jwt-utils.js | 86 +++++++++++ .../app/utils/jwt-utils.test.js | 134 ++++++++++++++++++ .../config/default.js | 3 +- .../package-lock.json | 10 ++ .../template-retail-react-app/package.json | 1 + 7 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 packages/template-retail-react-app/app/utils/jwt-utils.js create mode 100644 packages/template-retail-react-app/app/utils/jwt-utils.test.js diff --git a/packages/template-retail-react-app/app/pages/account/index.test.js b/packages/template-retail-react-app/app/pages/account/index.test.js index 4bb60defea..8dc447865a 100644 --- a/packages/template-retail-react-app/app/pages/account/index.test.js +++ b/packages/template-retail-react-app/app/pages/account/index.test.js @@ -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 () => { diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 80ce88673a..f152b457ac 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -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')) diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.js b/packages/template-retail-react-app/app/utils/jwt-utils.js new file mode 100644 index 0000000000..2f73d5c99a --- /dev/null +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -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}`}) + } +} diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.test.js b/packages/template-retail-react-app/app/utils/jwt-utils.test.js new file mode 100644 index 0000000000..bd9e49e048 --- /dev/null +++ b/packages/template-retail-react-app/app/utils/jwt-utils.test.js @@ -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() + }) +}) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 15b081aab1..7fbec2011e 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -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, diff --git a/packages/template-retail-react-app/package-lock.json b/packages/template-retail-react-app/package-lock.json index c540ff55b0..7ef4858b9e 100644 --- a/packages/template-retail-react-app/package-lock.json +++ b/packages/template-retail-react-app/package-lock.json @@ -35,6 +35,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", @@ -5516,6 +5517,15 @@ "node": ">=8" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jpeg-js": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index d32cfc587c..d957eaf1c2 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -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",