@salesforce/retail-react-app 6.0.0-preview.3 → 6.0.0-preview.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/app/ssr.js +199 -16
- package/babel.config.js +1 -18
- package/config/default.js +4 -2
- package/package.json +6 -6
- package/app/utils/jwt-utils.js +0 -88
- package/app/utils/jwt-utils.test.js +0 -134
- package/app/utils/marketing-cloud/marketing-cloud-email-link.js +0 -136
- package/app/utils/marketing-cloud/marketing-cloud-email-link.test.js +0 -65
package/CHANGELOG.md
CHANGED
package/app/ssr.js
CHANGED
|
@@ -16,22 +16,15 @@
|
|
|
16
16
|
|
|
17
17
|
'use strict'
|
|
18
18
|
|
|
19
|
+
import crypto from 'crypto'
|
|
20
|
+
import express from 'express'
|
|
21
|
+
import helmet from 'helmet'
|
|
22
|
+
import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
|
|
19
23
|
import path from 'path'
|
|
20
24
|
import {getRuntime} from '@salesforce/pwa-kit-runtime/ssr/server/express'
|
|
21
25
|
import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/middleware'
|
|
22
26
|
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
|
|
23
|
-
import
|
|
24
|
-
|
|
25
|
-
import express from 'express'
|
|
26
|
-
import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link'
|
|
27
|
-
import {
|
|
28
|
-
PASSWORDLESS_LOGIN_LANDING_PATH,
|
|
29
|
-
RESET_PASSWORD_LANDING_PATH
|
|
30
|
-
} from '@salesforce/retail-react-app/app/constants'
|
|
31
|
-
import {
|
|
32
|
-
validateSlasCallbackToken,
|
|
33
|
-
jwksCaching
|
|
34
|
-
} from '@salesforce/retail-react-app/app/utils/jwt-utils'
|
|
27
|
+
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
|
|
35
28
|
|
|
36
29
|
const config = getConfig()
|
|
37
30
|
|
|
@@ -78,6 +71,117 @@ const options = {
|
|
|
78
71
|
|
|
79
72
|
const runtime = getRuntime()
|
|
80
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Tokens are valid for 20 minutes. We store it at the top level scope to reuse
|
|
76
|
+
* it during the lambda invocation. We'll refresh it after 15 minutes.
|
|
77
|
+
*/
|
|
78
|
+
let marketingCloudToken = ''
|
|
79
|
+
let marketingCloudTokenExpiration = new Date()
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generates a unique ID for the email message.
|
|
83
|
+
*
|
|
84
|
+
* @return {string} A unique ID for the email message.
|
|
85
|
+
*/
|
|
86
|
+
function generateUniqueId() {
|
|
87
|
+
return crypto.randomBytes(16).toString('hex')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Sends an email to a specified contact using the Marketing Cloud API. The template email must have a
|
|
92
|
+
* `%%magic-link%%` personalization string inserted.
|
|
93
|
+
* https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5
|
|
94
|
+
*
|
|
95
|
+
* @param {string} email - The email address of the contact to whom the email will be sent.
|
|
96
|
+
* @param {string} templateId - The ID of the email template to be used for the email.
|
|
97
|
+
* @param {string} magicLink - The magic link to be included in the email.
|
|
98
|
+
*
|
|
99
|
+
* @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
|
|
100
|
+
*/
|
|
101
|
+
async function sendMarketingCloudEmail(emailId, marketingCloudConfig) {
|
|
102
|
+
// Refresh token if expired
|
|
103
|
+
if (new Date() > marketingCloudTokenExpiration) {
|
|
104
|
+
const {clientId, clientSecret, subdomain} = marketingCloudConfig
|
|
105
|
+
const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token`
|
|
106
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {'Content-Type': 'application/json'},
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
grant_type: 'client_credentials',
|
|
111
|
+
client_id: clientId,
|
|
112
|
+
client_secret: clientSecret
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
if (!tokenResponse.ok)
|
|
117
|
+
throw new Error(
|
|
118
|
+
'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.'
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const {access_token} = await tokenResponse.json()
|
|
122
|
+
marketingCloudToken = access_token
|
|
123
|
+
// Set expiration to 15 mins
|
|
124
|
+
marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Send the email
|
|
128
|
+
const emailUrl = `https://${
|
|
129
|
+
marketingCloudConfig.subdomain
|
|
130
|
+
}.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}`
|
|
131
|
+
const emailResponse = await fetch(emailUrl, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
Authorization: `Bearer ${marketingCloudToken}`,
|
|
135
|
+
'Content-Type': 'application/json'
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
definitionKey: marketingCloudConfig.templateId,
|
|
139
|
+
recipient: {
|
|
140
|
+
contactKey: emailId,
|
|
141
|
+
to: emailId,
|
|
142
|
+
attributes: {'magic-link': marketingCloudConfig.magicLink}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud')
|
|
148
|
+
|
|
149
|
+
return await emailResponse.json()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Generates a unique ID, constructs an email message URL, and sends the email to the specified contact
|
|
154
|
+
* using the Marketing Cloud API.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} email - The email address of the contact to whom the email will be sent.
|
|
157
|
+
* @param {string} templateId - The ID of the email template to be used for the email.
|
|
158
|
+
* @param {string} magicLink - The magic link to be included in the email.
|
|
159
|
+
*
|
|
160
|
+
* @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
|
|
161
|
+
*/
|
|
162
|
+
export async function emailLink(emailId, templateId, magicLink) {
|
|
163
|
+
if (!process.env.MARKETING_CLOUD_CLIENT_ID) {
|
|
164
|
+
console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) {
|
|
168
|
+
console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!process.env.MARKETING_CLOUD_SUBDOMAIN) {
|
|
172
|
+
console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const marketingCloudConfig = {
|
|
176
|
+
clientId: process.env.MARKETING_CLOUD_CLIENT_ID,
|
|
177
|
+
clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET,
|
|
178
|
+
magicLink: magicLink,
|
|
179
|
+
subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN,
|
|
180
|
+
templateId: templateId
|
|
181
|
+
}
|
|
182
|
+
return await sendMarketingCloudEmail(emailId, marketingCloudConfig)
|
|
183
|
+
}
|
|
184
|
+
|
|
81
185
|
const resetPasswordCallback =
|
|
82
186
|
config.app.login?.resetPassword?.callbackURI || '/reset-password-callback'
|
|
83
187
|
const passwordlessLoginCallback =
|
|
@@ -94,11 +198,11 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
|
|
|
94
198
|
|
|
95
199
|
// Construct the magic link URL
|
|
96
200
|
let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}`
|
|
97
|
-
if (landingPath ===
|
|
201
|
+
if (landingPath === config.app.login?.resetPassword?.landingPath) {
|
|
98
202
|
// Add email query parameter for reset password flow
|
|
99
203
|
magicLink += `&email=${encodeURIComponent(email_id)}`
|
|
100
204
|
}
|
|
101
|
-
if (landingPath ===
|
|
205
|
+
if (landingPath === config.app.login?.passwordless?.landingPath && redirectUrl) {
|
|
102
206
|
magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}`
|
|
103
207
|
}
|
|
104
208
|
|
|
@@ -109,6 +213,85 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
|
|
|
109
213
|
res.send(emailLinkResponse)
|
|
110
214
|
}
|
|
111
215
|
|
|
216
|
+
const CLAIM = {
|
|
217
|
+
ISSUER: 'iss'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const DELIMITER = {
|
|
221
|
+
ISSUER: '/'
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const throwSlasTokenValidationError = (message, code) => {
|
|
225
|
+
throw new Error(`SLAS Token Validation Error: ${message}`, code)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const createRemoteJWKSet = (tenantId) => {
|
|
229
|
+
const appOrigin = getAppOrigin()
|
|
230
|
+
const {app: appConfig} = getConfig()
|
|
231
|
+
const shortCode = appConfig.commerceAPI.parameters.shortCode
|
|
232
|
+
const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '')
|
|
233
|
+
if (tenantId !== configTenantId) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks`
|
|
239
|
+
return joseCreateRemoteJWKSet(new URL(JWKS_URI))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const validateSlasCallbackToken = async (token) => {
|
|
243
|
+
const payload = decodeJwt(token)
|
|
244
|
+
const subClaim = payload[CLAIM.ISSUER]
|
|
245
|
+
const tokens = subClaim.split(DELIMITER.ISSUER)
|
|
246
|
+
const tenantId = tokens[2]
|
|
247
|
+
try {
|
|
248
|
+
const jwks = createRemoteJWKSet(tenantId)
|
|
249
|
+
const {payload} = await jwtVerify(token, jwks, {})
|
|
250
|
+
return payload
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throwSlasTokenValidationError(error.message, 401)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const tenantIdRegExp = /^[a-zA-Z]{4}_([0-9]{3}|s[0-9]{2}|stg|dev|prd)$/
|
|
257
|
+
const shortCodeRegExp = /^[a-zA-Z0-9-]+$/
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Handles JWKS (JSON Web Key Set) caching the JWKS response for 2 weeks.
|
|
261
|
+
*
|
|
262
|
+
* @param {object} req Express request object.
|
|
263
|
+
* @param {object} res Express response object.
|
|
264
|
+
* @param {object} options Options for fetching B2C Commerce API JWKS.
|
|
265
|
+
* @param {string} options.shortCode - The Short Code assigned to the realm.
|
|
266
|
+
* @param {string} options.tenantId - The Tenant ID for the ECOM instance.
|
|
267
|
+
* @returns {Promise<*>} Promise with the JWKS data.
|
|
268
|
+
*/
|
|
269
|
+
export async function jwksCaching(req, res, options) {
|
|
270
|
+
const {shortCode, tenantId} = options
|
|
271
|
+
|
|
272
|
+
const isValidRequest = tenantIdRegExp.test(tenantId) && shortCodeRegExp.test(shortCode)
|
|
273
|
+
if (!isValidRequest)
|
|
274
|
+
return res
|
|
275
|
+
.status(400)
|
|
276
|
+
.json({error: 'Bad request parameters: Tenant ID or short code is invalid.'})
|
|
277
|
+
try {
|
|
278
|
+
const JWKS_URI = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/f_ecom_${tenantId}/oauth2/jwks`
|
|
279
|
+
const response = await fetch(JWKS_URI)
|
|
280
|
+
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
throw new Error('Request failed with status: ' + response.status)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// JWKS rotate every 30 days. For now, cache response for 14 days so that
|
|
286
|
+
// fetches only need to happen twice a month
|
|
287
|
+
res.set('Cache-Control', 'public, max-age=1209600, stale-while-revalidate=86400')
|
|
288
|
+
|
|
289
|
+
return res.json(await response.json())
|
|
290
|
+
} catch (error) {
|
|
291
|
+
res.status(400).json({error: `Error while fetching data: ${error.message}`})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
112
295
|
const {handler} = runtime.createHandler(options, (app) => {
|
|
113
296
|
app.use(express.json()) // To parse JSON payloads
|
|
114
297
|
app.use(express.urlencoded({extended: true}))
|
|
@@ -160,7 +343,7 @@ const {handler} = runtime.createHandler(options, (app) => {
|
|
|
160
343
|
sendMagicLinkEmail(
|
|
161
344
|
req,
|
|
162
345
|
res,
|
|
163
|
-
|
|
346
|
+
config.app.login?.passwordless?.landingPath,
|
|
164
347
|
process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE,
|
|
165
348
|
redirectUrl
|
|
166
349
|
)
|
|
@@ -177,7 +360,7 @@ const {handler} = runtime.createHandler(options, (app) => {
|
|
|
177
360
|
sendMagicLinkEmail(
|
|
178
361
|
req,
|
|
179
362
|
res,
|
|
180
|
-
|
|
363
|
+
config.app.login?.resetPassword?.landingPath,
|
|
181
364
|
process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE
|
|
182
365
|
)
|
|
183
366
|
})
|
package/babel.config.js
CHANGED
|
@@ -4,21 +4,4 @@
|
|
|
4
4
|
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
5
|
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
const baseConfig = require('@salesforce/pwa-kit-dev/configs/babel/babel-config')
|
|
9
|
-
|
|
10
|
-
module.exports = {
|
|
11
|
-
...baseConfig.default,
|
|
12
|
-
plugins: [
|
|
13
|
-
...baseConfig.default.plugins,
|
|
14
|
-
[
|
|
15
|
-
'module-resolver',
|
|
16
|
-
{
|
|
17
|
-
root: ['./'],
|
|
18
|
-
alias: {
|
|
19
|
-
'@salesforce/retail-react-app': './'
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
]
|
|
23
|
-
]
|
|
24
|
-
}
|
|
7
|
+
module.exports = require('@salesforce/pwa-kit-dev/configs/babel/babel-config')
|
package/config/default.js
CHANGED
|
@@ -19,7 +19,8 @@ module.exports = {
|
|
|
19
19
|
passwordless: {
|
|
20
20
|
enabled: false,
|
|
21
21
|
callbackURI:
|
|
22
|
-
process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback'
|
|
22
|
+
process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback',
|
|
23
|
+
landingPath: '/passwordless-login-landing'
|
|
23
24
|
},
|
|
24
25
|
social: {
|
|
25
26
|
enabled: false,
|
|
@@ -27,7 +28,8 @@ module.exports = {
|
|
|
27
28
|
redirectURI: process.env.SOCIAL_LOGIN_REDIRECT_URI || '/social-callback'
|
|
28
29
|
},
|
|
29
30
|
resetPassword: {
|
|
30
|
-
callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback'
|
|
31
|
+
callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback',
|
|
32
|
+
landingPath: '/reset-password-landing'
|
|
31
33
|
}
|
|
32
34
|
},
|
|
33
35
|
defaultSite: 'RefArchGlobal',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforce/retail-react-app",
|
|
3
|
-
"version": "6.0.0-preview.
|
|
3
|
+
"version": "6.0.0-preview.5",
|
|
4
4
|
"license": "See license in LICENSE",
|
|
5
5
|
"author": "cc-pwa-kit@salesforce.com",
|
|
6
6
|
"ccExtensibility": {
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"@lhci/cli": "^0.11.0",
|
|
46
46
|
"@loadable/component": "^5.15.3",
|
|
47
47
|
"@peculiar/webcrypto": "^1.4.2",
|
|
48
|
-
"@salesforce/commerce-sdk-react": "3.2.0-preview.
|
|
49
|
-
"@salesforce/pwa-kit-dev": "3.9.0-preview.
|
|
50
|
-
"@salesforce/pwa-kit-react-sdk": "3.9.0-preview.
|
|
51
|
-
"@salesforce/pwa-kit-runtime": "3.9.0-preview.
|
|
48
|
+
"@salesforce/commerce-sdk-react": "3.2.0-preview.5",
|
|
49
|
+
"@salesforce/pwa-kit-dev": "3.9.0-preview.5",
|
|
50
|
+
"@salesforce/pwa-kit-react-sdk": "3.9.0-preview.5",
|
|
51
|
+
"@salesforce/pwa-kit-runtime": "3.9.0-preview.5",
|
|
52
52
|
"@tanstack/react-query": "^4.28.0",
|
|
53
53
|
"@tanstack/react-query-devtools": "^4.29.1",
|
|
54
54
|
"@testing-library/dom": "^9.0.1",
|
|
@@ -101,5 +101,5 @@
|
|
|
101
101
|
"maxSize": "325 kB"
|
|
102
102
|
}
|
|
103
103
|
],
|
|
104
|
-
"gitHead": "
|
|
104
|
+
"gitHead": "e4b6ec6c2b694c561fc6902bec8a4e8c910f11fb"
|
|
105
105
|
}
|
package/app/utils/jwt-utils.js
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2025, salesforce.com, inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
|
|
8
|
-
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
|
|
9
|
-
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
|
|
10
|
-
|
|
11
|
-
const CLAIM = {
|
|
12
|
-
ISSUER: 'iss'
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const DELIMITER = {
|
|
16
|
-
ISSUER: '/'
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const throwSlasTokenValidationError = (message, code) => {
|
|
20
|
-
throw new Error(`SLAS Token Validation Error: ${message}`, code)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export const createRemoteJWKSet = (tenantId) => {
|
|
24
|
-
const appOrigin = getAppOrigin()
|
|
25
|
-
const {app: appConfig} = getConfig()
|
|
26
|
-
const shortCode = appConfig.commerceAPI.parameters.shortCode
|
|
27
|
-
const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '')
|
|
28
|
-
if (tenantId !== configTenantId) {
|
|
29
|
-
throw new Error(
|
|
30
|
-
`The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
|
-
const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks`
|
|
34
|
-
return joseCreateRemoteJWKSet(new URL(JWKS_URI))
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export const validateSlasCallbackToken = async (token) => {
|
|
38
|
-
const payload = decodeJwt(token)
|
|
39
|
-
const subClaim = payload[CLAIM.ISSUER]
|
|
40
|
-
const tokens = subClaim.split(DELIMITER.ISSUER)
|
|
41
|
-
const tenantId = tokens[2]
|
|
42
|
-
try {
|
|
43
|
-
const jwks = createRemoteJWKSet(tenantId)
|
|
44
|
-
const {payload} = await jwtVerify(token, jwks, {})
|
|
45
|
-
return payload
|
|
46
|
-
} catch (error) {
|
|
47
|
-
throwSlasTokenValidationError(error.message, 401)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const tenantIdRegExp = /^[a-zA-Z]{4}_([0-9]{3}|s[0-9]{2}|stg|dev|prd)$/
|
|
52
|
-
const shortCodeRegExp = /^[a-zA-Z0-9-]+$/
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Handles JWKS (JSON Web Key Set) caching the JWKS response for 2 weeks.
|
|
56
|
-
*
|
|
57
|
-
* @param {object} req Express request object.
|
|
58
|
-
* @param {object} res Express response object.
|
|
59
|
-
* @param {object} options Options for fetching B2C Commerce API JWKS.
|
|
60
|
-
* @param {string} options.shortCode - The Short Code assigned to the realm.
|
|
61
|
-
* @param {string} options.tenantId - The Tenant ID for the ECOM instance.
|
|
62
|
-
* @returns {Promise<*>} Promise with the JWKS data.
|
|
63
|
-
*/
|
|
64
|
-
export async function jwksCaching(req, res, options) {
|
|
65
|
-
const {shortCode, tenantId} = options
|
|
66
|
-
|
|
67
|
-
const isValidRequest = tenantIdRegExp.test(tenantId) && shortCodeRegExp.test(shortCode)
|
|
68
|
-
if (!isValidRequest)
|
|
69
|
-
return res
|
|
70
|
-
.status(400)
|
|
71
|
-
.json({error: 'Bad request parameters: Tenant ID or short code is invalid.'})
|
|
72
|
-
try {
|
|
73
|
-
const JWKS_URI = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/f_ecom_${tenantId}/oauth2/jwks`
|
|
74
|
-
const response = await fetch(JWKS_URI)
|
|
75
|
-
|
|
76
|
-
if (!response.ok) {
|
|
77
|
-
throw new Error('Request failed with status: ' + response.status)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// JWKS rotate every 30 days. For now, cache response for 14 days so that
|
|
81
|
-
// fetches only need to happen twice a month
|
|
82
|
-
res.set('Cache-Control', 'public, max-age=1209600, stale-while-revalidate=86400')
|
|
83
|
-
|
|
84
|
-
return res.json(await response.json())
|
|
85
|
-
} catch (error) {
|
|
86
|
-
res.status(400).json({error: `Error while fetching data: ${error.message}`})
|
|
87
|
-
}
|
|
88
|
-
}
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2025, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
|
|
8
|
-
import {
|
|
9
|
-
createRemoteJWKSet,
|
|
10
|
-
validateSlasCallbackToken
|
|
11
|
-
} from '@salesforce/retail-react-app/app/utils/jwt-utils'
|
|
12
|
-
import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
|
|
13
|
-
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
|
|
14
|
-
|
|
15
|
-
const MOCK_JWKS = {
|
|
16
|
-
keys: [
|
|
17
|
-
{
|
|
18
|
-
kty: 'EC',
|
|
19
|
-
crv: 'P-256',
|
|
20
|
-
use: 'sig',
|
|
21
|
-
kid: '8edb82b1-f6d5-49c1-bab2-c0d152ee3d0b',
|
|
22
|
-
x: 'i8e53csluQiqwP6Af8KsKgnUceXUE8_goFcvLuSzG3I',
|
|
23
|
-
y: 'yIH500tLKJtPhIl7MlMBOGvxQ_3U-VcrrXusr8bVr_0'
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
kty: 'EC',
|
|
27
|
-
crv: 'P-256',
|
|
28
|
-
use: 'sig',
|
|
29
|
-
kid: 'da9effc5-58cb-4a9c-9c9c-2919fb7d5e5e',
|
|
30
|
-
x: '_tAU1QSvcEkslcrbNBwx5V20-sN87z0zR7gcSdBETDQ',
|
|
31
|
-
y: 'ZJ7bgy7WrwJUGUtzcqm3MNyIfawI8F7fVawu5UwsN8E'
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
kty: 'EC',
|
|
35
|
-
crv: 'P-256',
|
|
36
|
-
use: 'sig',
|
|
37
|
-
kid: '5ccbbc6e-b234-4508-90f3-3b9b17efec16',
|
|
38
|
-
x: '9ULO2Atj5XToeWWAT6e6OhSHQftta4A3-djgOzcg4-Q',
|
|
39
|
-
y: 'JNuQSLMhakhLWN-c6Qi99tA5w-D7IFKf_apxVbVsK-g'
|
|
40
|
-
}
|
|
41
|
-
]
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
jest.mock('@salesforce/pwa-kit-react-sdk/utils/url', () => ({
|
|
45
|
-
getAppOrigin: jest.fn()
|
|
46
|
-
}))
|
|
47
|
-
|
|
48
|
-
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({
|
|
49
|
-
getConfig: jest.fn()
|
|
50
|
-
}))
|
|
51
|
-
|
|
52
|
-
jest.mock('jose', () => ({
|
|
53
|
-
createRemoteJWKSet: jest.fn(),
|
|
54
|
-
jwtVerify: jest.fn(),
|
|
55
|
-
decodeJwt: jest.fn()
|
|
56
|
-
}))
|
|
57
|
-
|
|
58
|
-
describe('createRemoteJWKSet', () => {
|
|
59
|
-
afterEach(() => {
|
|
60
|
-
jest.restoreAllMocks()
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('constructs the correct JWKS URI and call joseCreateRemoteJWKSet', () => {
|
|
64
|
-
const mockTenantId = 'aaaa_001'
|
|
65
|
-
const mockAppOrigin = 'https://test-storefront.com'
|
|
66
|
-
getAppOrigin.mockReturnValue(mockAppOrigin)
|
|
67
|
-
getConfig.mockReturnValue({
|
|
68
|
-
app: {
|
|
69
|
-
commerceAPI: {
|
|
70
|
-
parameters: {
|
|
71
|
-
shortCode: 'abc123',
|
|
72
|
-
organizationId: 'f_ecom_aaaa_001'
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
})
|
|
77
|
-
joseCreateRemoteJWKSet.mockReturnValue('mockJWKSet')
|
|
78
|
-
|
|
79
|
-
const expectedJWKS_URI = new URL(`${mockAppOrigin}/abc123/aaaa_001/oauth2/jwks`)
|
|
80
|
-
|
|
81
|
-
const res = createRemoteJWKSet(mockTenantId)
|
|
82
|
-
|
|
83
|
-
expect(getAppOrigin).toHaveBeenCalled()
|
|
84
|
-
expect(getConfig).toHaveBeenCalled()
|
|
85
|
-
expect(joseCreateRemoteJWKSet).toHaveBeenCalledWith(expectedJWKS_URI)
|
|
86
|
-
expect(res).toBe('mockJWKSet')
|
|
87
|
-
})
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
describe('validateSlasCallbackToken', () => {
|
|
91
|
-
beforeEach(() => {
|
|
92
|
-
jest.resetAllMocks()
|
|
93
|
-
const mockAppOrigin = 'https://test-storefront.com'
|
|
94
|
-
getAppOrigin.mockReturnValue(mockAppOrigin)
|
|
95
|
-
getConfig.mockReturnValue({
|
|
96
|
-
app: {
|
|
97
|
-
commerceAPI: {
|
|
98
|
-
parameters: {
|
|
99
|
-
shortCode: 'abc123',
|
|
100
|
-
organizationId: 'f_ecom_aaaa_001'
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
})
|
|
105
|
-
joseCreateRemoteJWKSet.mockReturnValue(MOCK_JWKS)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('returns payload when callback token is valid', async () => {
|
|
109
|
-
decodeJwt.mockReturnValue({iss: 'slas/dev/aaaa_001'})
|
|
110
|
-
const mockPayload = {sub: '123', role: 'admin'}
|
|
111
|
-
jwtVerify.mockResolvedValue({payload: mockPayload})
|
|
112
|
-
|
|
113
|
-
const res = await validateSlasCallbackToken('mock.slas.token')
|
|
114
|
-
|
|
115
|
-
expect(jwtVerify).toHaveBeenCalledWith('mock.slas.token', MOCK_JWKS, {})
|
|
116
|
-
expect(res).toEqual(mockPayload)
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
it('throws validation error when the token is invalid', async () => {
|
|
120
|
-
decodeJwt.mockReturnValue({iss: 'slas/dev/aaaa_001'})
|
|
121
|
-
const mockError = new Error('Invalid token')
|
|
122
|
-
jwtVerify.mockRejectedValue(mockError)
|
|
123
|
-
|
|
124
|
-
await expect(validateSlasCallbackToken('mock.slas.token')).rejects.toThrow(
|
|
125
|
-
mockError.message
|
|
126
|
-
)
|
|
127
|
-
expect(jwtVerify).toHaveBeenCalledWith('mock.slas.token', MOCK_JWKS, {})
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('throws mismatch error when the config tenantId does not match the jwt tenantId', async () => {
|
|
131
|
-
decodeJwt.mockReturnValue({iss: 'slas/dev/zzrf_001'})
|
|
132
|
-
await expect(validateSlasCallbackToken('mock.slas.token')).rejects.toThrow()
|
|
133
|
-
})
|
|
134
|
-
})
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2024, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* This file is responsible for integrating with the Marketing Cloud APIs to
|
|
10
|
-
* send emails with a magic link to a specified contact using the Marketing Cloud API.
|
|
11
|
-
* For this integration to work, a template email with a `%%magic-link%%` personalization string inserted
|
|
12
|
-
* must exist in your Marketing Cloud org.
|
|
13
|
-
*
|
|
14
|
-
* More details here: https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/transactional-messaging-get-started.html
|
|
15
|
-
*
|
|
16
|
-
* High Level Flow:
|
|
17
|
-
* 1. It retrieves an access token from the Marketing Cloud API using the
|
|
18
|
-
* provided client ID and client secret.
|
|
19
|
-
* 2. It constructs the email message URL using the generated unique ID and the
|
|
20
|
-
* provided template ID.
|
|
21
|
-
* 3. It sends the email message containing the magic link to the specified contact
|
|
22
|
-
* using the Marketing Cloud API.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import crypto from 'crypto'
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Tokens are valid for 20 minutes. We store it at the top level scope to reuse
|
|
29
|
-
* it during the lambda invocation. We'll refresh it after 15 minutes.
|
|
30
|
-
*/
|
|
31
|
-
let marketingCloudToken = ''
|
|
32
|
-
let marketingCloudTokenExpiration = new Date()
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Generates a unique ID for the email message.
|
|
36
|
-
*
|
|
37
|
-
* @return {string} A unique ID for the email message.
|
|
38
|
-
*/
|
|
39
|
-
function generateUniqueId() {
|
|
40
|
-
return crypto.randomBytes(16).toString('hex')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Sends an email to a specified contact using the Marketing Cloud API. The template email must have a
|
|
45
|
-
* `%%magic-link%%` personalization string inserted.
|
|
46
|
-
* https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5
|
|
47
|
-
*
|
|
48
|
-
* @param {string} email - The email address of the contact to whom the email will be sent.
|
|
49
|
-
* @param {string} templateId - The ID of the email template to be used for the email.
|
|
50
|
-
* @param {string} magicLink - The magic link to be included in the email.
|
|
51
|
-
*
|
|
52
|
-
* @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
|
|
53
|
-
*/
|
|
54
|
-
async function sendMarketingCloudEmail(emailId, marketingCloudConfig) {
|
|
55
|
-
// Refresh token if expired
|
|
56
|
-
if (new Date() > marketingCloudTokenExpiration) {
|
|
57
|
-
const {clientId, clientSecret, subdomain} = marketingCloudConfig
|
|
58
|
-
const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token`
|
|
59
|
-
const tokenResponse = await fetch(tokenUrl, {
|
|
60
|
-
method: 'POST',
|
|
61
|
-
headers: {'Content-Type': 'application/json'},
|
|
62
|
-
body: JSON.stringify({
|
|
63
|
-
grant_type: 'client_credentials',
|
|
64
|
-
client_id: clientId,
|
|
65
|
-
client_secret: clientSecret
|
|
66
|
-
})
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
if (!tokenResponse.ok)
|
|
70
|
-
throw new Error(
|
|
71
|
-
'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.'
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
const {access_token} = await tokenResponse.json()
|
|
75
|
-
marketingCloudToken = access_token
|
|
76
|
-
// Set expiration to 15 mins
|
|
77
|
-
marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Send the email
|
|
81
|
-
const emailUrl = `https://${
|
|
82
|
-
marketingCloudConfig.subdomain
|
|
83
|
-
}.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}`
|
|
84
|
-
const emailResponse = await fetch(emailUrl, {
|
|
85
|
-
method: 'POST',
|
|
86
|
-
headers: {
|
|
87
|
-
Authorization: `Bearer ${marketingCloudToken}`,
|
|
88
|
-
'Content-Type': 'application/json'
|
|
89
|
-
},
|
|
90
|
-
body: JSON.stringify({
|
|
91
|
-
definitionKey: marketingCloudConfig.templateId,
|
|
92
|
-
recipient: {
|
|
93
|
-
contactKey: emailId,
|
|
94
|
-
to: emailId,
|
|
95
|
-
attributes: {'magic-link': marketingCloudConfig.magicLink}
|
|
96
|
-
}
|
|
97
|
-
})
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud')
|
|
101
|
-
|
|
102
|
-
return await emailResponse.json()
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Generates a unique ID, constructs an email message URL, and sends the email to the specified contact
|
|
107
|
-
* using the Marketing Cloud API.
|
|
108
|
-
*
|
|
109
|
-
* @param {string} email - The email address of the contact to whom the email will be sent.
|
|
110
|
-
* @param {string} templateId - The ID of the email template to be used for the email.
|
|
111
|
-
* @param {string} magicLink - The magic link to be included in the email.
|
|
112
|
-
*
|
|
113
|
-
* @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
|
|
114
|
-
*/
|
|
115
|
-
export async function emailLink(emailId, templateId, magicLink) {
|
|
116
|
-
if (!process.env.MARKETING_CLOUD_CLIENT_ID) {
|
|
117
|
-
console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.')
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) {
|
|
121
|
-
console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.')
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (!process.env.MARKETING_CLOUD_SUBDOMAIN) {
|
|
125
|
-
console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const marketingCloudConfig = {
|
|
129
|
-
clientId: process.env.MARKETING_CLOUD_CLIENT_ID,
|
|
130
|
-
clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET,
|
|
131
|
-
magicLink: magicLink,
|
|
132
|
-
subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN,
|
|
133
|
-
templateId: templateId
|
|
134
|
-
}
|
|
135
|
-
return await sendMarketingCloudEmail(emailId, marketingCloudConfig)
|
|
136
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright (c) 2024, Salesforce, Inc.
|
|
3
|
-
* All rights reserved.
|
|
4
|
-
* SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
-
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
|
|
6
|
-
*/
|
|
7
|
-
import fetchMock from 'jest-fetch-mock'
|
|
8
|
-
import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link'
|
|
9
|
-
|
|
10
|
-
const fetchOriginal = global.fetch
|
|
11
|
-
const originalEnv = process.env
|
|
12
|
-
|
|
13
|
-
beforeAll(() => {
|
|
14
|
-
global.fetch = fetchMock
|
|
15
|
-
global.fetch.mockResponse(JSON.stringify({}))
|
|
16
|
-
process.env = {
|
|
17
|
-
...originalEnv,
|
|
18
|
-
MARKETING_CLOUD_CLIENT_ID: 'mc_client_id',
|
|
19
|
-
MARKETING_CLOUD_CLIENT_SECRET: 'mc_client_secret',
|
|
20
|
-
MARKETING_CLOUD_SUBDOMAIN: 'mc_subdomain.com'
|
|
21
|
-
}
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
afterAll(() => {
|
|
25
|
-
global.fetch = fetchOriginal
|
|
26
|
-
process.env = originalEnv
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
describe('emailLink()', () => {
|
|
30
|
-
it('should send an email with a magic link', async () => {
|
|
31
|
-
const email = 'test@example.com'
|
|
32
|
-
const templateId = '123'
|
|
33
|
-
const magicLink = 'https://magic-link.example.com'
|
|
34
|
-
await emailLink(email, templateId, magicLink)
|
|
35
|
-
|
|
36
|
-
expect(fetch).toHaveBeenCalledTimes(2)
|
|
37
|
-
expect(fetch).toHaveBeenNthCalledWith(
|
|
38
|
-
1,
|
|
39
|
-
`https://${process.env.MARKETING_CLOUD_SUBDOMAIN}.auth.marketingcloudapis.com/v2/token`,
|
|
40
|
-
{
|
|
41
|
-
body: JSON.stringify({
|
|
42
|
-
grant_type: 'client_credentials',
|
|
43
|
-
client_id: process.env.MARKETING_CLOUD_CLIENT_ID,
|
|
44
|
-
client_secret: process.env.MARKETING_CLOUD_CLIENT_SECRET
|
|
45
|
-
}),
|
|
46
|
-
headers: {'Content-Type': 'application/json'},
|
|
47
|
-
method: 'POST'
|
|
48
|
-
}
|
|
49
|
-
)
|
|
50
|
-
expect(fetch).toHaveBeenNthCalledWith(
|
|
51
|
-
2,
|
|
52
|
-
expect.stringContaining(
|
|
53
|
-
`https://${process.env.MARKETING_CLOUD_SUBDOMAIN}.rest.marketingcloudapis.com/messaging/v1/email/messages/`
|
|
54
|
-
),
|
|
55
|
-
{
|
|
56
|
-
body: JSON.stringify({
|
|
57
|
-
definitionKey: templateId,
|
|
58
|
-
recipient: {contactKey: email, to: email, attributes: {'magic-link': magicLink}}
|
|
59
|
-
}),
|
|
60
|
-
headers: expect.objectContaining({'Content-Type': 'application/json'}),
|
|
61
|
-
method: 'POST'
|
|
62
|
-
}
|
|
63
|
-
)
|
|
64
|
-
})
|
|
65
|
-
})
|