From 58e50392f8cad763b4dca95ca92684671ca2db9e Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 2 Jan 2025 11:15:48 -0500 Subject: [PATCH 01/14] changes from previous branch --- packages/template-retail-react-app/app/ssr.js | 43 +++++++++++++++++++ .../static/translations/compiled/en-XA.json | 2 +- .../app/utils/jwt-utils.js | 33 ++++++++++++++ .../template-retail-react-app/package.json | 1 + 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 packages/template-retail-react-app/app/utils/jwt-utils.js diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index af27810b86..42c05195cc 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -67,6 +67,45 @@ const options = { encodeNonAsciiHttpHeaders: true } +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. + */ +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}`}) + } +} + const runtime = getRuntime() const resetPasswordCallback = @@ -131,6 +170,10 @@ 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. diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index d89e33bb53..60a331010d 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -8569,4 +8569,4 @@ "value": "]" } ] -} \ No newline at end of file +} 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..7bb53e91f9 --- /dev/null +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -0,0 +1,33 @@ +/* + * 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 jose from 'jose' +import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' + + +const throwSlasTokenValidationError = (message, code) => { + throw new Error(`SLAS Token Validation Error: ${message}`, code) +} + +const createRemoteJWKSet = () => { + const appOrigin = getAppOrigin() + const {app: appConfig} = getConfig() + const shortCode = appConfig.commerceApi.parameters.shortCode + const tenantId = appConfig.commerceApi.parameters.organizationId + const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks` + return jose.createRemoteJWKSet(new URL(JWKS_URI)) +} + +export const verifySlasCallbackToken = async (token) => { + try { + const jwks = createRemoteJWKSet(new URL(jwksUri)) + const {payload} = await jose.jwtVerify(token, jwks, {}) + return payload + } catch (error) { + throwSlasTokenValidationError(error.message, 401) + } +} \ No newline at end of file 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", From 32e0d425f6fb50d48898f7c8d394508870ad2e4a Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 2 Jan 2025 11:40:40 -0500 Subject: [PATCH 02/14] verify callbacktoken in post callbacks --- packages/template-retail-react-app/app/ssr.js | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 42c05195cc..9915a20e33 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -28,6 +28,7 @@ import { PASSWORDLESS_LOGIN_LANDING_PATH, RESET_PASSWORD_LANDING_PATH } from '@salesforce/retail-react-app/app/constants' +import {verifySlasCallbackToken} from '@salesforce/retail-react-app/app/utils/jwt-utils' const config = getConfig() @@ -179,12 +180,16 @@ const {handler} = runtime.createHandler(options, (app) => { // 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 - ) + const slasCallbackToken = req.headers.get('x-slas-callback-token') + verifySlasCallbackToken(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 @@ -192,12 +197,16 @@ const {handler} = runtime.createHandler(options, (app) => { // 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 - ) + const slasCallbackToken = req.headers.get('x-slas-callback-token') + verifySlasCallbackToken(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')) From 0ab40525c0a93205e8f6897f9891ae2298090a2d Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 2 Jan 2025 12:34:49 -0500 Subject: [PATCH 03/14] add package lock --- packages/template-retail-react-app/package-lock.json | 10 ++++++++++ 1 file changed, 10 insertions(+) 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", From 33be681aa6a8de284c8192f11441015f7b043d7a Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Thu, 2 Jan 2025 14:20:13 -0500 Subject: [PATCH 04/14] cleanup --- packages/template-retail-react-app/app/ssr.js | 22 +-- .../static/translations/compiled/en-XA.json | 2 +- .../app/utils/jwt-utils.js | 16 +- .../app/utils/jwt-utils.test.js | 141 ++++++++++++++++++ 4 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 packages/template-retail-react-app/app/utils/jwt-utils.test.js diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 9915a20e33..8f8a3f801e 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -23,12 +23,13 @@ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import helmet from 'helmet' import express from 'express' -import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link' +//TODO: Revert to absolute path before merge +import {emailLink} from './utils/marketing-cloud/marketing-cloud-email-link' import { PASSWORDLESS_LOGIN_LANDING_PATH, RESET_PASSWORD_LANDING_PATH -} from '@salesforce/retail-react-app/app/constants' -import {verifySlasCallbackToken} from '@salesforce/retail-react-app/app/utils/jwt-utils' +} from './constants' +import {validateSlasCallbackToken} from './utils/jwt-utils' const config = getConfig() @@ -55,7 +56,7 @@ const options = { // Set this to false if using a SLAS public client // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set - useSLASPrivateClient: true, + useSLASPrivateClient: false, applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless|password\/(login|token|reset|action))/, @@ -138,6 +139,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 @@ -180,8 +183,8 @@ const {handler} = runtime.createHandler(options, (app) => { // 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) => { - const slasCallbackToken = req.headers.get('x-slas-callback-token') - verifySlasCallbackToken(slasCallbackToken) + const slasCallbackToken = req.headers['x-slas-callback-token'] + validateSlasCallbackToken(slasCallbackToken) .then(() => { sendMagicLinkEmail( req, @@ -196,9 +199,10 @@ const {handler} = runtime.createHandler(options, (app) => { // 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) => { - const slasCallbackToken = req.headers.get('x-slas-callback-token') - verifySlasCallbackToken(slasCallbackToken) + app.post(resetPasswordCallback, (req, res) => { + console.log('hellooooooooo') + const slasCallbackToken = req.headers['x-slas-callback-token'] + validateSlasCallbackToken(slasCallbackToken) .then(() => { sendMagicLinkEmail( req, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 60a331010d..d89e33bb53 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -8569,4 +8569,4 @@ "value": "]" } ] -} +} \ No newline at end of file diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.js b/packages/template-retail-react-app/app/utils/jwt-utils.js index 7bb53e91f9..25bb0df1f5 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -4,7 +4,7 @@ * 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 jose from 'jose' +import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify} from 'jose' import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' @@ -16,16 +16,18 @@ const throwSlasTokenValidationError = (message, code) => { const createRemoteJWKSet = () => { const appOrigin = getAppOrigin() const {app: appConfig} = getConfig() - const shortCode = appConfig.commerceApi.parameters.shortCode - const tenantId = appConfig.commerceApi.parameters.organizationId + const shortCode = appConfig.commerceAPI.parameters.shortCode + const tenantId = appConfig.commerceAPI.parameters.organizationId const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks` - return jose.createRemoteJWKSet(new URL(JWKS_URI)) + console.log('THIS IS THE JWKS_URI: ' + JWKS_URI) + return joseCreateRemoteJWKSet(new URL(JWKS_URI)) } -export const verifySlasCallbackToken = async (token) => { +export const validateSlasCallbackToken = async (token) => { try { - const jwks = createRemoteJWKSet(new URL(jwksUri)) - const {payload} = await jose.jwtVerify(token, jwks, {}) + const jwks = createRemoteJWKSet() + const {payload} = await jwtVerify(token, jwks, {}) + console.log('THIS IS THE PAYLOAD: ', payload) return payload } catch (error) { throwSlasTokenValidationError(error.message, 401) 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..68b14679cf --- /dev/null +++ b/packages/template-retail-react-app/app/utils/jwt-utils.test.js @@ -0,0 +1,141 @@ +const jose = require('jose') +const assert = require('assert') +const JwtMinter = require('./jwt-minter') +const SlasTokenValidator = require('./slas-token-validator') + +const AUTH_ERROR_NAME = 'AuthError' + +const issueTimestamp = Math.round(Date.now() / 1000) - 60 +const JWT_SAMPLE_PAYLOAD = { + aut: 'GUID', + scp: + // eslint-disable-next-line + 'sfcc.shopper-myaccount.baskets sfcc.shopper-myaccount.addresses sfcc.ts_int_on_behalf_of, sfcc.shopper-myaccount.rw openid sfcc.shopper-customers.login sfcc.shopper-customers.register sfcc.shopper-myaccount.addresses.rw offline offline_access sfcc.ts_ext_on_behalf_of email sfcc.shopper-categories sfcc.shopper-myaccount sfcc.pwdless_login', + sub: + 'cc-slas::bcgl_stg::scid:726bde86-7b99-415d-98ec-9290bad18904::usid:b92318ea-8b0b-40e1-9ad1-2b673b61bf03', + ctx: 'slas', + iss: 'slas/dev/bcgl_stg', + ist: 1, + aud: 'commercecloud/dev/bcgl_stg', + nbf: issueTimestamp, + sty: 'User', + isb: + 'uido:ecom::upn:mikew::uidn:Mike Wazowski::gcid:abwHIWkXc2xucRmegUwGYYkesV::rcid:bcTMaLNWamdrJu03XHk51s8kcO::chid:TestChid', + exp: issueTimestamp + 60 * 30, + iat: issueTimestamp, + jti: 'C2C8694720750-1008298052332081994449379' +} +const SLAS_JWT_HEADER_JKU = 'slas/dev/bcgl_stg' + +const assertAuthError = (error, expectedCode, expectedMessage) => { + assert.strictEqual(error.name, AUTH_ERROR_NAME) + assert.strictEqual(error.code, expectedCode) + assert.strictEqual(error.message, expectedMessage) + return true +} + +describe('createRemoteJWKSet', () => { +}) + +describe('SLAS Token Validator', () => { + const jwtMinter = new JwtMinter() + + beforeAll(async() => { + const JWKS = (await jwtMinter.getKeyPair()).cert + + // For testing, we mock the remote JWKS with the local mocks + // to avoid making network calls during testing. + jest.spyOn(SlasTokenValidator.prototype, 'createRemoteJWKSet').mockImplementation( + (protectedHeader, token) => { + return jose.createLocalJWKSet(JWKS)(protectedHeader, token) + } + ) + }) + + it('verify() Verify JWT - Verification passes for test JWT.', async() => { + // Arrange. + const jwt = await jwtMinter.mint(JWT_SAMPLE_PAYLOAD, SLAS_JWT_HEADER_JKU) + const slasTokenValidator = new SlasTokenValidator(jwt) + + // Act. + const tokenValidationResult = await slasTokenValidator.verify() + + // Assert + assert.strictEqual(tokenValidationResult.expiration, issueTimestamp + 60 * 30) + }) + + + it('verify() Verify JWT - Verification fails if JWT has no issuer claim.', async() => { + // Arrange + const invalidPayload = {...JWT_SAMPLE_PAYLOAD} + delete invalidPayload.iss + const jwt = await jwtMinter.mint(invalidPayload, SLAS_JWT_HEADER_JKU) + const slasTokenValidator = new SlasTokenValidator(jwt) + // Act + await assert.rejects( + () => slasTokenValidator.verify(), + // Assert + (error) => + assertAuthError( + error, + 403, + 'SLAS Token Validation Error: Invalid SLAS \'iss\' claim.' + ) + ) + }) + + it('verify() Verify JWT - Verification fails if JWT header JKU fields does not contain a SLAS issuer.', async() => { + // Arrange + const jwt = await jwtMinter.mint(JWT_SAMPLE_PAYLOAD, 'fooo/dev/bcgl_stg') + const slasTokenValidator = new SlasTokenValidator(jwt) + // Act + await assert.rejects( + () => slasTokenValidator.verify(), + // Assert + (error) => + assertAuthError( + error, + 401, + 'SLAS Token Validation Error: Invalid jku header. Expected a SLAS tenant.' + ) + ) + }) + + it('verify() Verify JWT - Verification fails if JWT has not a SLAS issuer claim.', async() => { + // Arrange + const invalidPayload = {...JWT_SAMPLE_PAYLOAD, iss: 'fooo/dev/bcgl_stg'} + const jwt = await jwtMinter.mint(invalidPayload, SLAS_JWT_HEADER_JKU) + const slasTokenValidator = new SlasTokenValidator(jwt) + // Act + await assert.rejects( + () => slasTokenValidator.verify(), + // Assert + (error) => + assertAuthError( + error, + 403, + 'SLAS Token Validation Error: Invalid SLAS \'iss\' claim. Expected a SLAS tenant.' + ) + ) + }) + + it('verify() Verify JWT - Verification fails if JWT was signed with a different key.', async() => { + // Arrange + const foreignMinter = new JwtMinter() + const jwt = await foreignMinter.mint(JWT_SAMPLE_PAYLOAD, SLAS_JWT_HEADER_JKU) + const slasTokenValidator = new SlasTokenValidator(jwt) + // Act + await assert.rejects( + () => { + return slasTokenValidator.verify() + }, + // Assert + (error) => + assertAuthError( + error, + 401, + 'SLAS Token Validation Error: signature verification failed' + ) + ) + }) +}) From adbccc9102d80f7c163bb04275734bf413ae9b29 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Mon, 6 Jan 2025 11:40:27 -0500 Subject: [PATCH 05/14] regex check tenant id --- packages/template-retail-react-app/app/ssr.js | 3 ++- packages/template-retail-react-app/app/utils/jwt-utils.js | 4 +--- packages/template-retail-react-app/config/default.js | 8 ++++---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 8f8a3f801e..ee5d512bd3 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -56,6 +56,8 @@ const options = { // Set this to false if using a SLAS public client // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set + + //TODO: Revert before merge useSLASPrivateClient: false, applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless|password\/(login|token|reset|action))/, @@ -200,7 +202,6 @@ const {handler} = runtime.createHandler(options, (app) => { // 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, (req, res) => { - console.log('hellooooooooo') const slasCallbackToken = req.headers['x-slas-callback-token'] validateSlasCallbackToken(slasCallbackToken) .then(() => { diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.js b/packages/template-retail-react-app/app/utils/jwt-utils.js index 25bb0df1f5..de825ca108 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -17,9 +17,8 @@ const createRemoteJWKSet = () => { const appOrigin = getAppOrigin() const {app: appConfig} = getConfig() const shortCode = appConfig.commerceAPI.parameters.shortCode - const tenantId = appConfig.commerceAPI.parameters.organizationId + const tenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '') const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks` - console.log('THIS IS THE JWKS_URI: ' + JWKS_URI) return joseCreateRemoteJWKSet(new URL(JWKS_URI)) } @@ -27,7 +26,6 @@ export const validateSlasCallbackToken = async (token) => { try { const jwks = createRemoteJWKSet() const {payload} = await jwtVerify(token, jwks, {}) - console.log('THIS IS THE PAYLOAD: ', payload) return payload } catch (error) { throwSlasTokenValidationError(error.message, 401) diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 42aa6ae179..31a3d3b3e5 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -38,9 +38,9 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', - organizationId: 'f_ecom_zzrf_001', - shortCode: '8o7m175y', + clientId: '58f1b970-50be-4ec4-8124-25a1ed943b8b', + organizationId: 'f_ecom_bgvn_stg', + shortCode: 'sandbox-001', siteId: 'RefArchGlobal' } }, @@ -67,7 +67,7 @@ module.exports = { ssrFunctionNodeVersion: '20.x', proxyConfigs: [ { - host: 'kv7kzm78.api.commercecloud.salesforce.com', + host: 'sandbox-001.api.commercecloud.salesforce.com', path: 'api' }, { From 55cefa6ccc495610b878c2d48f2d194148e4cea0 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 14 Jan 2025 11:15:16 -0500 Subject: [PATCH 06/14] add jest tests --- .../app/utils/jwt-utils.js | 2 +- .../app/utils/jwt-utils.test.js | 195 +++++++++--------- 2 files changed, 98 insertions(+), 99 deletions(-) diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.js b/packages/template-retail-react-app/app/utils/jwt-utils.js index de825ca108..bdbc6c547b 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -13,7 +13,7 @@ const throwSlasTokenValidationError = (message, code) => { throw new Error(`SLAS Token Validation Error: ${message}`, code) } -const createRemoteJWKSet = () => { +export const createRemoteJWKSet = () => { const appOrigin = getAppOrigin() const {app: appConfig} = getConfig() const shortCode = appConfig.commerceAPI.parameters.shortCode 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 index 68b14679cf..7353328fec 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.test.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.test.js @@ -1,7 +1,7 @@ -const jose = require('jose') -const assert = require('assert') -const JwtMinter = require('./jwt-minter') -const SlasTokenValidator = require('./slas-token-validator') +import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify} from 'jose' +import {createRemoteJWKSet, validateSlasCallbackToken} from './jwt-utils' +import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' const AUTH_ERROR_NAME = 'AuthError' @@ -25,117 +25,116 @@ const JWT_SAMPLE_PAYLOAD = { iat: issueTimestamp, jti: 'C2C8694720750-1008298052332081994449379' } -const SLAS_JWT_HEADER_JKU = 'slas/dev/bcgl_stg' -const assertAuthError = (error, expectedCode, expectedMessage) => { - assert.strictEqual(error.name, AUTH_ERROR_NAME) - assert.strictEqual(error.code, expectedCode) - assert.strictEqual(error.message, expectedMessage) - return true +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" + } + ] } -describe('createRemoteJWKSet', () => { -}) +jest.mock('@salesforce/pwa-kit-react-sdk/utils/url', () => ({ + getAppOrigin: jest.fn() +})) -describe('SLAS Token Validator', () => { - const jwtMinter = new JwtMinter() +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ + getConfig: jest.fn() +})) - beforeAll(async() => { - const JWKS = (await jwtMinter.getKeyPair()).cert +jest.mock('jose', () => ({ + createRemoteJWKSet: jest.fn(), + jwtVerify: jest.fn() +})) - // For testing, we mock the remote JWKS with the local mocks - // to avoid making network calls during testing. - jest.spyOn(SlasTokenValidator.prototype, 'createRemoteJWKSet').mockImplementation( - (protectedHeader, token) => { - return jose.createLocalJWKSet(JWKS)(protectedHeader, token) - } - ) +describe('createRemoteJWKSet', () => { + + afterEach(() => { + jest.restoreAllMocks() }) - it('verify() Verify JWT - Verification passes for test JWT.', async() => { - // Arrange. - const jwt = await jwtMinter.mint(JWT_SAMPLE_PAYLOAD, SLAS_JWT_HEADER_JKU) - const slasTokenValidator = new SlasTokenValidator(jwt) + it('constructs the correct JWKS URI and call joseCreateRemoteJWKSet', () => { + 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`) - // Act. - const tokenValidationResult = await slasTokenValidator.verify() + const res = createRemoteJWKSet() - // Assert - assert.strictEqual(tokenValidationResult.expiration, issueTimestamp + 60 * 30) + expect(getAppOrigin).toHaveBeenCalled() + expect(getConfig).toHaveBeenCalled() + expect(joseCreateRemoteJWKSet).toHaveBeenCalledWith(expectedJWKS_URI) + expect(res).toBe('mockJWKSet') }) +}) - it('verify() Verify JWT - Verification fails if JWT has no issuer claim.', async() => { - // Arrange - const invalidPayload = {...JWT_SAMPLE_PAYLOAD} - delete invalidPayload.iss - const jwt = await jwtMinter.mint(invalidPayload, SLAS_JWT_HEADER_JKU) - const slasTokenValidator = new SlasTokenValidator(jwt) - // Act - await assert.rejects( - () => slasTokenValidator.verify(), - // Assert - (error) => - assertAuthError( - error, - 403, - 'SLAS Token Validation Error: Invalid SLAS \'iss\' claim.' - ) - ) - }) +describe('validateSlasCallbackToken', () => { - it('verify() Verify JWT - Verification fails if JWT header JKU fields does not contain a SLAS issuer.', async() => { - // Arrange - const jwt = await jwtMinter.mint(JWT_SAMPLE_PAYLOAD, 'fooo/dev/bcgl_stg') - const slasTokenValidator = new SlasTokenValidator(jwt) - // Act - await assert.rejects( - () => slasTokenValidator.verify(), - // Assert - (error) => - assertAuthError( - error, - 401, - 'SLAS Token Validation Error: Invalid jku header. Expected a SLAS tenant.' - ) - ) + 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('verify() Verify JWT - Verification fails if JWT has not a SLAS issuer claim.', async() => { - // Arrange - const invalidPayload = {...JWT_SAMPLE_PAYLOAD, iss: 'fooo/dev/bcgl_stg'} - const jwt = await jwtMinter.mint(invalidPayload, SLAS_JWT_HEADER_JKU) - const slasTokenValidator = new SlasTokenValidator(jwt) - // Act - await assert.rejects( - () => slasTokenValidator.verify(), - // Assert - (error) => - assertAuthError( - error, - 403, - 'SLAS Token Validation Error: Invalid SLAS \'iss\' claim. Expected a SLAS tenant.' - ) - ) + it('returns payload when callback token is valid', async() => { + 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('verify() Verify JWT - Verification fails if JWT was signed with a different key.', async() => { - // Arrange - const foreignMinter = new JwtMinter() - const jwt = await foreignMinter.mint(JWT_SAMPLE_PAYLOAD, SLAS_JWT_HEADER_JKU) - const slasTokenValidator = new SlasTokenValidator(jwt) - // Act - await assert.rejects( - () => { - return slasTokenValidator.verify() - }, - // Assert - (error) => - assertAuthError( - error, - 401, - 'SLAS Token Validation Error: signature verification failed' - ) - ) + it('throws validation error when the token is invalid', async() => { + 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, {}) }) }) From 1817222f6510a575b70492313d183ec6104839e0 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 14 Jan 2025 11:16:30 -0500 Subject: [PATCH 07/14] revert changes --- packages/template-retail-react-app/app/ssr.js | 14 ++++++++------ .../template-retail-react-app/config/default.js | 12 ++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index ee5d512bd3..ad5f199402 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -23,13 +23,12 @@ import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import helmet from 'helmet' import express from 'express' -//TODO: Revert to absolute path before merge -import {emailLink} from './utils/marketing-cloud/marketing-cloud-email-link' +import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link' import { PASSWORDLESS_LOGIN_LANDING_PATH, RESET_PASSWORD_LANDING_PATH } from './constants' -import {validateSlasCallbackToken} from './utils/jwt-utils' +import {validateSlasCallbackToken} from '@salesforce/retail-react-app/app/utils/jwt-utils' const config = getConfig() @@ -57,7 +56,6 @@ const options = { // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set - //TODO: Revert before merge useSLASPrivateClient: false, applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless|password\/(login|token|reset|action))/, @@ -94,7 +92,11 @@ async function jwksCaching(req, res, options) { .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) + const response = await fetch(JWKS_URI, { + headers: { + 'User-Agent': 'OctoperfMercuryPerfTest' + } + }) if (!response.ok) { throw new Error('Request failed with status: ' + response.status) @@ -184,7 +186,7 @@ const {handler} = runtime.createHandler(options, (app) => { // 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) => { + app.post(passwordlessLoginCallback, (req, res) => { const slasCallbackToken = req.headers['x-slas-callback-token'] validateSlasCallbackToken(slasCallbackToken) .then(() => { diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index 31a3d3b3e5..c4ccbb8dd7 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -17,11 +17,11 @@ module.exports = { }, login: { passwordless: { - enabled: false, + enabled: true, callbackURI: '/passwordless-login-callback' }, social: { - enabled: false, + enabled: true, idps: ['google', 'apple'], redirectURI: '/social-callback' }, @@ -38,9 +38,9 @@ module.exports = { commerceAPI: { proxyPath: `/mobify/proxy/api`, parameters: { - clientId: '58f1b970-50be-4ec4-8124-25a1ed943b8b', - organizationId: 'f_ecom_bgvn_stg', - shortCode: 'sandbox-001', + clientId: 'c9c45bfd-0ed3-4aa2-9971-40f88962b836', + organizationId: 'f_ecom_zzrf_001', + shortCode: '8o7m175y', siteId: 'RefArchGlobal' } }, @@ -67,7 +67,7 @@ module.exports = { ssrFunctionNodeVersion: '20.x', proxyConfigs: [ { - host: 'sandbox-001.api.commercecloud.salesforce.com', + host: 'kv7kzm78.api.commercecloud.salesforce.com', path: 'api' }, { From 7ffd098045bdf35f8535af2137a57225adeec3d1 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 14 Jan 2025 11:17:39 -0500 Subject: [PATCH 08/14] revert changes --- packages/template-retail-react-app/app/ssr.js | 4 ++-- packages/template-retail-react-app/config/default.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index ad5f199402..71461617d7 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -27,7 +27,7 @@ import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/ import { PASSWORDLESS_LOGIN_LANDING_PATH, RESET_PASSWORD_LANDING_PATH -} from './constants' +} from '@salesforce/retail-react-app/app/constants' import {validateSlasCallbackToken} from '@salesforce/retail-react-app/app/utils/jwt-utils' const config = getConfig() @@ -56,7 +56,7 @@ const options = { // When setting this to true, make sure to also set the PWA_KIT_SLAS_CLIENT_SECRET // environment variable as this endpoint will return HTTP 501 if it is not set - useSLASPrivateClient: false, + useSLASPrivateClient: true, applySLASPrivateClientToEndpoints: /oauth2\/(token|passwordless|password\/(login|token|reset|action))/, diff --git a/packages/template-retail-react-app/config/default.js b/packages/template-retail-react-app/config/default.js index c4ccbb8dd7..42aa6ae179 100644 --- a/packages/template-retail-react-app/config/default.js +++ b/packages/template-retail-react-app/config/default.js @@ -17,11 +17,11 @@ module.exports = { }, login: { passwordless: { - enabled: true, + enabled: false, callbackURI: '/passwordless-login-callback' }, social: { - enabled: true, + enabled: false, idps: ['google', 'apple'], redirectURI: '/social-callback' }, From 692e937b21793945eeac283d0e15e34c72c62792 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 14 Jan 2025 11:18:14 -0500 Subject: [PATCH 09/14] cleanup tests --- .../app/utils/jwt-utils.test.js | 23 ------------------- 1 file changed, 23 deletions(-) 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 index 7353328fec..003eeb37d4 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.test.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.test.js @@ -3,29 +3,6 @@ import {createRemoteJWKSet, validateSlasCallbackToken} from './jwt-utils' import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' -const AUTH_ERROR_NAME = 'AuthError' - -const issueTimestamp = Math.round(Date.now() / 1000) - 60 -const JWT_SAMPLE_PAYLOAD = { - aut: 'GUID', - scp: - // eslint-disable-next-line - 'sfcc.shopper-myaccount.baskets sfcc.shopper-myaccount.addresses sfcc.ts_int_on_behalf_of, sfcc.shopper-myaccount.rw openid sfcc.shopper-customers.login sfcc.shopper-customers.register sfcc.shopper-myaccount.addresses.rw offline offline_access sfcc.ts_ext_on_behalf_of email sfcc.shopper-categories sfcc.shopper-myaccount sfcc.pwdless_login', - sub: - 'cc-slas::bcgl_stg::scid:726bde86-7b99-415d-98ec-9290bad18904::usid:b92318ea-8b0b-40e1-9ad1-2b673b61bf03', - ctx: 'slas', - iss: 'slas/dev/bcgl_stg', - ist: 1, - aud: 'commercecloud/dev/bcgl_stg', - nbf: issueTimestamp, - sty: 'User', - isb: - 'uido:ecom::upn:mikew::uidn:Mike Wazowski::gcid:abwHIWkXc2xucRmegUwGYYkesV::rcid:bcTMaLNWamdrJu03XHk51s8kcO::chid:TestChid', - exp: issueTimestamp + 60 * 30, - iat: issueTimestamp, - jti: 'C2C8694720750-1008298052332081994449379' -} - const MOCK_JWKS = { "keys": [ { From 9e38e2d9d7d30fe0dad43e32732e81c2066bd94e Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Tue, 21 Jan 2025 10:57:18 -0500 Subject: [PATCH 10/14] move jwkscaching function to jwtutils --- packages/template-retail-react-app/app/ssr.js | 81 +++++-------------- .../app/utils/jwt-utils.js | 46 ++++++++++- .../app/utils/jwt-utils.test.js | 62 +++++++------- 3 files changed, 97 insertions(+), 92 deletions(-) diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js index 71461617d7..d31237cf51 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -28,7 +28,7 @@ import { PASSWORDLESS_LOGIN_LANDING_PATH, RESET_PASSWORD_LANDING_PATH } from '@salesforce/retail-react-app/app/constants' -import {validateSlasCallbackToken} from '@salesforce/retail-react-app/app/utils/jwt-utils' +import {validateSlasCallbackToken, jwksCaching} from '@salesforce/retail-react-app/app/utils/jwt-utils' const config = getConfig() @@ -69,49 +69,6 @@ const options = { encodeNonAsciiHttpHeaders: true } -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. - */ -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, { - headers: { - 'User-Agent': 'OctoperfMercuryPerfTest' - } - }) - - 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}`}) - } -} - const runtime = getRuntime() const resetPasswordCallback = @@ -144,7 +101,7 @@ 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 })) + app.use(express.urlencoded({extended: true})) // Set default HTTP security headers required by PWA Kit app.use(defaultPwaKitSecurityHeaders) // Set custom HTTP security headers @@ -188,15 +145,14 @@ const {handler} = runtime.createHandler(options, (app) => { // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-passwordless-login.html#receive-the-callback 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 - ) - }) + 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 @@ -205,15 +161,14 @@ const {handler} = runtime.createHandler(options, (app) => { // https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-password-reset.html#slas-password-reset-flow 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 - ) - }) + 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 index bdbc6c547b..a8718a3f87 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -8,7 +8,6 @@ import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify} from 'jose' import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' - const throwSlasTokenValidationError = (message, code) => { throw new Error(`SLAS Token Validation Error: ${message}`, code) } @@ -30,4 +29,47 @@ export const validateSlasCallbackToken = async (token) => { } catch (error) { throwSlasTokenValidationError(error.message, 401) } -} \ No newline at end of file +} + +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, { + headers: { + 'User-Agent': 'OctoperfMercuryPerfTest' + } + }) + + 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 index 003eeb37d4..7d49b009fb 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.test.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.test.js @@ -1,33 +1,42 @@ +/* + * 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} from 'jose' -import {createRemoteJWKSet, validateSlasCallbackToken} from './jwt-utils' +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": [ + 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: '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: '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" + kty: 'EC', + crv: 'P-256', + use: 'sig', + kid: '5ccbbc6e-b234-4508-90f3-3b9b17efec16', + x: '9ULO2Atj5XToeWWAT6e6OhSHQftta4A3-djgOzcg4-Q', + y: 'JNuQSLMhakhLWN-c6Qi99tA5w-D7IFKf_apxVbVsK-g' } ] } @@ -46,7 +55,6 @@ jest.mock('jose', () => ({ })) describe('createRemoteJWKSet', () => { - afterEach(() => { jest.restoreAllMocks() }) @@ -75,11 +83,9 @@ describe('createRemoteJWKSet', () => { expect(joseCreateRemoteJWKSet).toHaveBeenCalledWith(expectedJWKS_URI) expect(res).toBe('mockJWKSet') }) - }) describe('validateSlasCallbackToken', () => { - beforeEach(() => { jest.resetAllMocks() const mockAppOrigin = 'https://test-storefront.com' @@ -97,8 +103,8 @@ describe('validateSlasCallbackToken', () => { joseCreateRemoteJWKSet.mockReturnValue(MOCK_JWKS) }) - it('returns payload when callback token is valid', async() => { - const mockPayload = { sub: '123', role: 'admin' } + it('returns payload when callback token is valid', async () => { + const mockPayload = {sub: '123', role: 'admin'} jwtVerify.mockResolvedValue({payload: mockPayload}) const res = await validateSlasCallbackToken('mock.slas.token') @@ -107,11 +113,13 @@ describe('validateSlasCallbackToken', () => { expect(res).toEqual(mockPayload) }) - it('throws validation error when the token is invalid', async() => { + it('throws validation error when the token is invalid', async () => { const mockError = new Error('Invalid token') jwtVerify.mockRejectedValue(mockError) - await expect(validateSlasCallbackToken('mock.slas.token')).rejects.toThrow(mockError.message) + await expect(validateSlasCallbackToken('mock.slas.token')).rejects.toThrow( + mockError.message + ) expect(jwtVerify).toHaveBeenCalledWith('mock.slas.token', MOCK_JWKS, {}) }) }) From 18a8199aedcd5cf476ca59aad0c884e50ec9f9d0 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 22 Jan 2025 13:40:02 -0500 Subject: [PATCH 11/14] remove octoperf header --- .../app/pages/account/index.test.js | 5 +---- packages/template-retail-react-app/app/ssr.js | 5 ++++- packages/template-retail-react-app/app/utils/jwt-utils.js | 6 +----- packages/template-retail-react-app/config/default.js | 3 ++- 4 files changed, 8 insertions(+), 11 deletions(-) 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 8123cf03ad..f152b457ac 100644 --- a/packages/template-retail-react-app/app/ssr.js +++ b/packages/template-retail-react-app/app/ssr.js @@ -28,7 +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' +import { + validateSlasCallbackToken, + jwksCaching +} from '@salesforce/retail-react-app/app/utils/jwt-utils' const config = getConfig() diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.js b/packages/template-retail-react-app/app/utils/jwt-utils.js index a8718a3f87..3d6ebae597 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -54,11 +54,7 @@ export async function jwksCaching(req, res, options) { .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, { - headers: { - 'User-Agent': 'OctoperfMercuryPerfTest' - } - }) + const response = await fetch(JWKS_URI) if (!response.ok) { throw new Error('Request failed with status: ' + response.status) 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, From 590f420b38e4e747195f380ccbe431399448a908 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 22 Jan 2025 15:21:45 -0500 Subject: [PATCH 12/14] grab tenant id from jwt --- .../app/utils/jwt-utils.js | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.js b/packages/template-retail-react-app/app/utils/jwt-utils.js index 3d6ebae597..0efa76a6c0 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -4,26 +4,41 @@ * 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} from 'jose' +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 = () => { +export const createRemoteJWKSet = (tenantId) => { const appOrigin = getAppOrigin() const {app: appConfig} = getConfig() const shortCode = appConfig.commerceAPI.parameters.shortCode - const tenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '') + 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() + const jwks = createRemoteJWKSet(tenantId) const {payload} = await jwtVerify(token, jwks, {}) return payload } catch (error) { @@ -54,7 +69,11 @@ export async function jwksCaching(req, res, options) { .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) + const response = await fetch(JWKS_URI, { + headers: { + 'User-Agent': 'OctoperfMercuryPerfTest' + } + }) if (!response.ok) { throw new Error('Request failed with status: ' + response.status) From a6b1dc01346b0eb26b5091a5e7b328f9241f2ff4 Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 22 Jan 2025 15:30:32 -0500 Subject: [PATCH 13/14] add test for tenant id check --- .../app/utils/jwt-utils.test.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 index 7d49b009fb..bd9e49e048 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.test.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.test.js @@ -4,11 +4,11 @@ * 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} from 'jose' +import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose' import { createRemoteJWKSet, validateSlasCallbackToken -} from '@salesforce/retail-react-app/../../app/utils/jwt-utils' +} 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' @@ -51,7 +51,8 @@ jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ jest.mock('jose', () => ({ createRemoteJWKSet: jest.fn(), - jwtVerify: jest.fn() + jwtVerify: jest.fn(), + decodeJwt: jest.fn() })) describe('createRemoteJWKSet', () => { @@ -60,6 +61,7 @@ describe('createRemoteJWKSet', () => { }) it('constructs the correct JWKS URI and call joseCreateRemoteJWKSet', () => { + const mockTenantId = 'aaaa_001' const mockAppOrigin = 'https://test-storefront.com' getAppOrigin.mockReturnValue(mockAppOrigin) getConfig.mockReturnValue({ @@ -76,7 +78,7 @@ describe('createRemoteJWKSet', () => { const expectedJWKS_URI = new URL(`${mockAppOrigin}/abc123/aaaa_001/oauth2/jwks`) - const res = createRemoteJWKSet() + const res = createRemoteJWKSet(mockTenantId) expect(getAppOrigin).toHaveBeenCalled() expect(getConfig).toHaveBeenCalled() @@ -104,6 +106,7 @@ describe('validateSlasCallbackToken', () => { }) 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}) @@ -114,6 +117,7 @@ describe('validateSlasCallbackToken', () => { }) 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) @@ -122,4 +126,9 @@ describe('validateSlasCallbackToken', () => { ) 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() + }) }) From 82891a1eb23b02c1ce2cec41646d31b11f39891b Mon Sep 17 00:00:00 2001 From: yunakim714 Date: Wed, 22 Jan 2025 18:11:34 -0500 Subject: [PATCH 14/14] remove octoperf header --- packages/template-retail-react-app/app/utils/jwt-utils.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.js b/packages/template-retail-react-app/app/utils/jwt-utils.js index 0efa76a6c0..2f73d5c99a 100644 --- a/packages/template-retail-react-app/app/utils/jwt-utils.js +++ b/packages/template-retail-react-app/app/utils/jwt-utils.js @@ -69,11 +69,7 @@ export async function jwksCaching(req, res, options) { .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, { - headers: { - 'User-Agent': 'OctoperfMercuryPerfTest' - } - }) + const response = await fetch(JWKS_URI) if (!response.ok) { throw new Error('Request failed with status: ' + response.status)