@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 CHANGED
@@ -1,3 +1,5 @@
1
+ ## v6.0.0-preview.5 (Feb 17, 2025)
2
+
1
3
  ## v6.0.0
2
4
  - DNT Consent Banner: [#2203](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2203)
3
5
  - Support Node 22 [#2218](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2218)
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 helmet from 'helmet'
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 === RESET_PASSWORD_LANDING_PATH) {
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 === PASSWORDLESS_LOGIN_LANDING_PATH && redirectUrl) {
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
- PASSWORDLESS_LOGIN_LANDING_PATH,
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
- RESET_PASSWORD_LANDING_PATH,
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
- // eslint-disable-next-line @typescript-eslint/no-var-requires
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",
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.3",
49
- "@salesforce/pwa-kit-dev": "3.9.0-preview.3",
50
- "@salesforce/pwa-kit-react-sdk": "3.9.0-preview.3",
51
- "@salesforce/pwa-kit-runtime": "3.9.0-preview.3",
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": "5fc2978c2b1ac9156613b01226dc9d0a42d24c32"
104
+ "gitHead": "e4b6ec6c2b694c561fc6902bec8a4e8c910f11fb"
105
105
  }
@@ -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
- })