@salesforce/pwa-kit-create-app 3.9.0-preview.2 → 3.9.0-preview.4

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.
@@ -25,7 +25,9 @@ module.exports = {
25
25
  // Required in 'callback' mode; if missing, passwordless login defaults to 'sms' mode, which requires Marketing Cloud configuration.
26
26
  // If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value.
27
27
  callbackURI:
28
- process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback'
28
+ process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback',
29
+ // The landing path for passwordless login
30
+ landingPath: '/passwordless-login-landing'
29
31
  },
30
32
  social: {
31
33
  // Enables or disables social login for the site. Defaults to: false
@@ -41,7 +43,9 @@ module.exports = {
41
43
  resetPassword: {
42
44
  // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer.
43
45
  // If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value.
44
- callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback'
46
+ callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback',
47
+ // The landing path for reset password
48
+ landingPath: '/reset-password-landing'
45
49
  }
46
50
  },
47
51
  // The default site for your app. This value will be used when a siteRef could not be determined from the url
@@ -27,6 +27,7 @@ import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/compon
27
27
  import {useCorrelationId} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks'
28
28
  import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
29
29
  import {ReactQueryDevtools} from '@tanstack/react-query-devtools'
30
+ import {DEFAULT_DNT_STATE} from '@salesforce/retail-react-app/app/constants'
30
31
 
31
32
  /**
32
33
  * Use the AppConfig component to inject extra arguments into the getProps
@@ -57,6 +58,7 @@ const AppConfig = ({children, locals = {}}) => {
57
58
  redirectURI={`${appOrigin}/callback`}
58
59
  proxy={`${appOrigin}${commerceApiConfig.proxyPath}`}
59
60
  headers={headers}
61
+ defaultDnt={DEFAULT_DNT_STATE}
60
62
  logger={createLogger({packageName: 'commerce-sdk-react'})}
61
63
  {{#if answers.project.commerce.isSlasPrivate}}
62
64
  // Set 'enablePWAKitPrivateClient' to true use SLAS private client login flows.
@@ -7,22 +7,15 @@
7
7
 
8
8
  'use strict'
9
9
 
10
+ import crypto from 'crypto'
11
+ import express from 'express'
12
+ import helmet from 'helmet'
13
+ import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
10
14
  import path from 'path'
11
15
  import {getRuntime} from '@salesforce/pwa-kit-runtime/ssr/server/express'
12
16
  import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/middleware'
13
17
  import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
14
- import helmet from 'helmet'
15
-
16
- import express from 'express'
17
- import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link'
18
- import {
19
- PASSWORDLESS_LOGIN_LANDING_PATH,
20
- RESET_PASSWORD_LANDING_PATH
21
- } from '@salesforce/retail-react-app/app/constants'
22
- import {
23
- validateSlasCallbackToken,
24
- jwksCaching
25
- } from '@salesforce/retail-react-app/app/utils/jwt-utils'
18
+ import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
26
19
 
27
20
  const config = getConfig()
28
21
 
@@ -62,6 +55,117 @@ const options = {
62
55
 
63
56
  const runtime = getRuntime()
64
57
 
58
+ /**
59
+ * Tokens are valid for 20 minutes. We store it at the top level scope to reuse
60
+ * it during the lambda invocation. We'll refresh it after 15 minutes.
61
+ */
62
+ let marketingCloudToken = ''
63
+ let marketingCloudTokenExpiration = new Date()
64
+
65
+ /**
66
+ * Generates a unique ID for the email message.
67
+ *
68
+ * @return {string} A unique ID for the email message.
69
+ */
70
+ function generateUniqueId() {
71
+ return crypto.randomBytes(16).toString('hex')
72
+ }
73
+
74
+ /**
75
+ * Sends an email to a specified contact using the Marketing Cloud API. The template email must have a
76
+ * `%%magic-link%%` personalization string inserted.
77
+ * https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5
78
+ *
79
+ * @param {string} email - The email address of the contact to whom the email will be sent.
80
+ * @param {string} templateId - The ID of the email template to be used for the email.
81
+ * @param {string} magicLink - The magic link to be included in the email.
82
+ *
83
+ * @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
84
+ */
85
+ async function sendMarketingCloudEmail(emailId, marketingCloudConfig) {
86
+ // Refresh token if expired
87
+ if (new Date() > marketingCloudTokenExpiration) {
88
+ const {clientId, clientSecret, subdomain} = marketingCloudConfig
89
+ const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token`
90
+ const tokenResponse = await fetch(tokenUrl, {
91
+ method: 'POST',
92
+ headers: {'Content-Type': 'application/json'},
93
+ body: JSON.stringify({
94
+ grant_type: 'client_credentials',
95
+ client_id: clientId,
96
+ client_secret: clientSecret
97
+ })
98
+ })
99
+
100
+ if (!tokenResponse.ok)
101
+ throw new Error(
102
+ 'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.'
103
+ )
104
+
105
+ const {access_token} = await tokenResponse.json()
106
+ marketingCloudToken = access_token
107
+ // Set expiration to 15 mins
108
+ marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000)
109
+ }
110
+
111
+ // Send the email
112
+ const emailUrl = `https://${
113
+ marketingCloudConfig.subdomain
114
+ }.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}`
115
+ const emailResponse = await fetch(emailUrl, {
116
+ method: 'POST',
117
+ headers: {
118
+ Authorization: `Bearer ${marketingCloudToken}`,
119
+ 'Content-Type': 'application/json'
120
+ },
121
+ body: JSON.stringify({
122
+ definitionKey: marketingCloudConfig.templateId,
123
+ recipient: {
124
+ contactKey: emailId,
125
+ to: emailId,
126
+ attributes: {'magic-link': marketingCloudConfig.magicLink}
127
+ }
128
+ })
129
+ })
130
+
131
+ if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud')
132
+
133
+ return await emailResponse.json()
134
+ }
135
+
136
+ /**
137
+ * Generates a unique ID, constructs an email message URL, and sends the email to the specified contact
138
+ * using the Marketing Cloud API.
139
+ *
140
+ * @param {string} email - The email address of the contact to whom the email will be sent.
141
+ * @param {string} templateId - The ID of the email template to be used for the email.
142
+ * @param {string} magicLink - The magic link to be included in the email.
143
+ *
144
+ * @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
145
+ */
146
+ export async function emailLink(emailId, templateId, magicLink) {
147
+ if (!process.env.MARKETING_CLOUD_CLIENT_ID) {
148
+ console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.')
149
+ }
150
+
151
+ if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) {
152
+ console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.')
153
+ }
154
+
155
+ if (!process.env.MARKETING_CLOUD_SUBDOMAIN) {
156
+ console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.')
157
+ }
158
+
159
+ const marketingCloudConfig = {
160
+ clientId: process.env.MARKETING_CLOUD_CLIENT_ID,
161
+ clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET,
162
+ magicLink: magicLink,
163
+ subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN,
164
+ templateId: templateId
165
+ }
166
+ return await sendMarketingCloudEmail(emailId, marketingCloudConfig)
167
+ }
168
+
65
169
  const resetPasswordCallback =
66
170
  config.app.login?.resetPassword?.callbackURI || '/reset-password-callback'
67
171
  const passwordlessLoginCallback =
@@ -78,11 +182,11 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
78
182
 
79
183
  // Construct the magic link URL
80
184
  let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}`
81
- if (landingPath === RESET_PASSWORD_LANDING_PATH) {
185
+ if (landingPath === config.app.login?.resetPassword?.landingPath) {
82
186
  // Add email query parameter for reset password flow
83
187
  magicLink += `&email=${encodeURIComponent(email_id)}`
84
188
  }
85
- if (landingPath === PASSWORDLESS_LOGIN_LANDING_PATH && redirectUrl) {
189
+ if (landingPath === config.app.login?.passwordless?.landingPath && redirectUrl) {
86
190
  magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}`
87
191
  }
88
192
 
@@ -93,6 +197,85 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
93
197
  res.send(emailLinkResponse)
94
198
  }
95
199
 
200
+ const CLAIM = {
201
+ ISSUER: 'iss'
202
+ }
203
+
204
+ const DELIMITER = {
205
+ ISSUER: '/'
206
+ }
207
+
208
+ const throwSlasTokenValidationError = (message, code) => {
209
+ throw new Error(`SLAS Token Validation Error: ${message}`, code)
210
+ }
211
+
212
+ export const createRemoteJWKSet = (tenantId) => {
213
+ const appOrigin = getAppOrigin()
214
+ const {app: appConfig} = getConfig()
215
+ const shortCode = appConfig.commerceAPI.parameters.shortCode
216
+ const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '')
217
+ if (tenantId !== configTenantId) {
218
+ throw new Error(
219
+ `The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`
220
+ )
221
+ }
222
+ const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks`
223
+ return joseCreateRemoteJWKSet(new URL(JWKS_URI))
224
+ }
225
+
226
+ export const validateSlasCallbackToken = async (token) => {
227
+ const payload = decodeJwt(token)
228
+ const subClaim = payload[CLAIM.ISSUER]
229
+ const tokens = subClaim.split(DELIMITER.ISSUER)
230
+ const tenantId = tokens[2]
231
+ try {
232
+ const jwks = createRemoteJWKSet(tenantId)
233
+ const {payload} = await jwtVerify(token, jwks, {})
234
+ return payload
235
+ } catch (error) {
236
+ throwSlasTokenValidationError(error.message, 401)
237
+ }
238
+ }
239
+
240
+ const tenantIdRegExp = /^[a-zA-Z]{4}_([0-9]{3}|s[0-9]{2}|stg|dev|prd)$/
241
+ const shortCodeRegExp = /^[a-zA-Z0-9-]+$/
242
+
243
+ /**
244
+ * Handles JWKS (JSON Web Key Set) caching the JWKS response for 2 weeks.
245
+ *
246
+ * @param {object} req Express request object.
247
+ * @param {object} res Express response object.
248
+ * @param {object} options Options for fetching B2C Commerce API JWKS.
249
+ * @param {string} options.shortCode - The Short Code assigned to the realm.
250
+ * @param {string} options.tenantId - The Tenant ID for the ECOM instance.
251
+ * @returns {Promise<*>} Promise with the JWKS data.
252
+ */
253
+ export async function jwksCaching(req, res, options) {
254
+ const {shortCode, tenantId} = options
255
+
256
+ const isValidRequest = tenantIdRegExp.test(tenantId) && shortCodeRegExp.test(shortCode)
257
+ if (!isValidRequest)
258
+ return res
259
+ .status(400)
260
+ .json({error: 'Bad request parameters: Tenant ID or short code is invalid.'})
261
+ try {
262
+ const JWKS_URI = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/f_ecom_${tenantId}/oauth2/jwks`
263
+ const response = await fetch(JWKS_URI)
264
+
265
+ if (!response.ok) {
266
+ throw new Error('Request failed with status: ' + response.status)
267
+ }
268
+
269
+ // JWKS rotate every 30 days. For now, cache response for 14 days so that
270
+ // fetches only need to happen twice a month
271
+ res.set('Cache-Control', 'public, max-age=1209600, stale-while-revalidate=86400')
272
+
273
+ return res.json(await response.json())
274
+ } catch (error) {
275
+ res.status(400).json({error: `Error while fetching data: ${error.message}`})
276
+ }
277
+ }
278
+
96
279
  const {handler} = runtime.createHandler(options, (app) => {
97
280
  app.use(express.json()) // To parse JSON payloads
98
281
  app.use(express.urlencoded({extended: true}))
@@ -144,7 +327,7 @@ const {handler} = runtime.createHandler(options, (app) => {
144
327
  sendMagicLinkEmail(
145
328
  req,
146
329
  res,
147
- PASSWORDLESS_LOGIN_LANDING_PATH,
330
+ config.app.login?.passwordless?.landingPath,
148
331
  process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE,
149
332
  redirectUrl
150
333
  )
@@ -161,7 +344,7 @@ const {handler} = runtime.createHandler(options, (app) => {
161
344
  sendMagicLinkEmail(
162
345
  req,
163
346
  res,
164
- RESET_PASSWORD_LANDING_PATH,
347
+ config.app.login?.resetPassword?.landingPath,
165
348
  process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE
166
349
  )
167
350
  })
@@ -27,6 +27,7 @@ import {CommerceApiProvider} from '@salesforce/commerce-sdk-react'
27
27
  import {withReactQuery} from '@salesforce/pwa-kit-react-sdk/ssr/universal/components/with-react-query'
28
28
  import {useCorrelationId} from '@salesforce/pwa-kit-react-sdk/ssr/universal/hooks'
29
29
  import {ReactQueryDevtools} from '@tanstack/react-query-devtools'
30
+ import {DEFAULT_DNT_STATE} from '@salesforce/retail-react-app/app/constants'
30
31
 
31
32
  /**
32
33
  * Use the AppConfig component to inject extra arguments into the getProps
@@ -57,6 +58,7 @@ const AppConfig = ({children, locals = {}}) => {
57
58
  redirectURI={`${appOrigin}/callback`}
58
59
  proxy={`${appOrigin}${commerceApiConfig.proxyPath}`}
59
60
  headers={headers}
61
+ defaultDnt={DEFAULT_DNT_STATE}
60
62
  logger={createLogger({packageName: 'commerce-sdk-react'})}
61
63
  {{#if answers.project.commerce.isSlasPrivate}}
62
64
  // Set 'enablePWAKitPrivateClient' to true use SLAS private client login flows.
@@ -7,22 +7,15 @@
7
7
 
8
8
  'use strict'
9
9
 
10
+ import crypto from 'crypto'
11
+ import express from 'express'
12
+ import helmet from 'helmet'
13
+ import {createRemoteJWKSet as joseCreateRemoteJWKSet, jwtVerify, decodeJwt} from 'jose'
10
14
  import path from 'path'
11
15
  import {getRuntime} from '@salesforce/pwa-kit-runtime/ssr/server/express'
12
16
  import {defaultPwaKitSecurityHeaders} from '@salesforce/pwa-kit-runtime/utils/middleware'
13
17
  import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
14
- import helmet from 'helmet'
15
-
16
- import express from 'express'
17
- import {emailLink} from '@salesforce/retail-react-app/app/utils/marketing-cloud/marketing-cloud-email-link'
18
- import {
19
- PASSWORDLESS_LOGIN_LANDING_PATH,
20
- RESET_PASSWORD_LANDING_PATH
21
- } from '@salesforce/retail-react-app/app/constants'
22
- import {
23
- validateSlasCallbackToken,
24
- jwksCaching
25
- } from '@salesforce/retail-react-app/app/utils/jwt-utils'
18
+ import {getAppOrigin} from '@salesforce/pwa-kit-react-sdk/utils/url'
26
19
 
27
20
  const config = getConfig()
28
21
 
@@ -62,6 +55,117 @@ const options = {
62
55
 
63
56
  const runtime = getRuntime()
64
57
 
58
+ /**
59
+ * Tokens are valid for 20 minutes. We store it at the top level scope to reuse
60
+ * it during the lambda invocation. We'll refresh it after 15 minutes.
61
+ */
62
+ let marketingCloudToken = ''
63
+ let marketingCloudTokenExpiration = new Date()
64
+
65
+ /**
66
+ * Generates a unique ID for the email message.
67
+ *
68
+ * @return {string} A unique ID for the email message.
69
+ */
70
+ function generateUniqueId() {
71
+ return crypto.randomBytes(16).toString('hex')
72
+ }
73
+
74
+ /**
75
+ * Sends an email to a specified contact using the Marketing Cloud API. The template email must have a
76
+ * `%%magic-link%%` personalization string inserted.
77
+ * https://help.salesforce.com/s/articleView?id=mktg.mc_es_personalization_strings.htm&type=5
78
+ *
79
+ * @param {string} email - The email address of the contact to whom the email will be sent.
80
+ * @param {string} templateId - The ID of the email template to be used for the email.
81
+ * @param {string} magicLink - The magic link to be included in the email.
82
+ *
83
+ * @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
84
+ */
85
+ async function sendMarketingCloudEmail(emailId, marketingCloudConfig) {
86
+ // Refresh token if expired
87
+ if (new Date() > marketingCloudTokenExpiration) {
88
+ const {clientId, clientSecret, subdomain} = marketingCloudConfig
89
+ const tokenUrl = `https://${subdomain}.auth.marketingcloudapis.com/v2/token`
90
+ const tokenResponse = await fetch(tokenUrl, {
91
+ method: 'POST',
92
+ headers: {'Content-Type': 'application/json'},
93
+ body: JSON.stringify({
94
+ grant_type: 'client_credentials',
95
+ client_id: clientId,
96
+ client_secret: clientSecret
97
+ })
98
+ })
99
+
100
+ if (!tokenResponse.ok)
101
+ throw new Error(
102
+ 'Failed to fetch Marketing Cloud access token. Check your Marketing Cloud credentials and try again.'
103
+ )
104
+
105
+ const {access_token} = await tokenResponse.json()
106
+ marketingCloudToken = access_token
107
+ // Set expiration to 15 mins
108
+ marketingCloudTokenExpiration = new Date(Date.now() + 15 * 60 * 1000)
109
+ }
110
+
111
+ // Send the email
112
+ const emailUrl = `https://${
113
+ marketingCloudConfig.subdomain
114
+ }.rest.marketingcloudapis.com/messaging/v1/email/messages/${generateUniqueId()}`
115
+ const emailResponse = await fetch(emailUrl, {
116
+ method: 'POST',
117
+ headers: {
118
+ Authorization: `Bearer ${marketingCloudToken}`,
119
+ 'Content-Type': 'application/json'
120
+ },
121
+ body: JSON.stringify({
122
+ definitionKey: marketingCloudConfig.templateId,
123
+ recipient: {
124
+ contactKey: emailId,
125
+ to: emailId,
126
+ attributes: {'magic-link': marketingCloudConfig.magicLink}
127
+ }
128
+ })
129
+ })
130
+
131
+ if (!emailResponse.ok) throw new Error('Failed to send email to Marketing Cloud')
132
+
133
+ return await emailResponse.json()
134
+ }
135
+
136
+ /**
137
+ * Generates a unique ID, constructs an email message URL, and sends the email to the specified contact
138
+ * using the Marketing Cloud API.
139
+ *
140
+ * @param {string} email - The email address of the contact to whom the email will be sent.
141
+ * @param {string} templateId - The ID of the email template to be used for the email.
142
+ * @param {string} magicLink - The magic link to be included in the email.
143
+ *
144
+ * @return {Promise<object>} A promise that resolves to the response object received from the Marketing Cloud API.
145
+ */
146
+ export async function emailLink(emailId, templateId, magicLink) {
147
+ if (!process.env.MARKETING_CLOUD_CLIENT_ID) {
148
+ console.warn('MARKETING_CLOUD_CLIENT_ID is not set in the environment variables.')
149
+ }
150
+
151
+ if (!process.env.MARKETING_CLOUD_CLIENT_SECRET) {
152
+ console.warn(' MARKETING_CLOUD_CLIENT_SECRET is not set in the environment variables.')
153
+ }
154
+
155
+ if (!process.env.MARKETING_CLOUD_SUBDOMAIN) {
156
+ console.warn('MARKETING_CLOUD_SUBDOMAIN is not set in the environment variables.')
157
+ }
158
+
159
+ const marketingCloudConfig = {
160
+ clientId: process.env.MARKETING_CLOUD_CLIENT_ID,
161
+ clientSecret: process.env.MARKETING_CLOUD_CLIENT_SECRET,
162
+ magicLink: magicLink,
163
+ subdomain: process.env.MARKETING_CLOUD_SUBDOMAIN,
164
+ templateId: templateId
165
+ }
166
+ return await sendMarketingCloudEmail(emailId, marketingCloudConfig)
167
+ }
168
+
65
169
  const resetPasswordCallback =
66
170
  config.app.login?.resetPassword?.callbackURI || '/reset-password-callback'
67
171
  const passwordlessLoginCallback =
@@ -78,11 +182,11 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
78
182
 
79
183
  // Construct the magic link URL
80
184
  let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}`
81
- if (landingPath === RESET_PASSWORD_LANDING_PATH) {
185
+ if (landingPath === config.app.login?.resetPassword?.landingPath) {
82
186
  // Add email query parameter for reset password flow
83
187
  magicLink += `&email=${encodeURIComponent(email_id)}`
84
188
  }
85
- if (landingPath === PASSWORDLESS_LOGIN_LANDING_PATH && redirectUrl) {
189
+ if (landingPath === config.app.login?.passwordless?.landingPath && redirectUrl) {
86
190
  magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}`
87
191
  }
88
192
 
@@ -93,6 +197,85 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirect
93
197
  res.send(emailLinkResponse)
94
198
  }
95
199
 
200
+ const CLAIM = {
201
+ ISSUER: 'iss'
202
+ }
203
+
204
+ const DELIMITER = {
205
+ ISSUER: '/'
206
+ }
207
+
208
+ const throwSlasTokenValidationError = (message, code) => {
209
+ throw new Error(`SLAS Token Validation Error: ${message}`, code)
210
+ }
211
+
212
+ export const createRemoteJWKSet = (tenantId) => {
213
+ const appOrigin = getAppOrigin()
214
+ const {app: appConfig} = getConfig()
215
+ const shortCode = appConfig.commerceAPI.parameters.shortCode
216
+ const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '')
217
+ if (tenantId !== configTenantId) {
218
+ throw new Error(
219
+ `The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`
220
+ )
221
+ }
222
+ const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks`
223
+ return joseCreateRemoteJWKSet(new URL(JWKS_URI))
224
+ }
225
+
226
+ export const validateSlasCallbackToken = async (token) => {
227
+ const payload = decodeJwt(token)
228
+ const subClaim = payload[CLAIM.ISSUER]
229
+ const tokens = subClaim.split(DELIMITER.ISSUER)
230
+ const tenantId = tokens[2]
231
+ try {
232
+ const jwks = createRemoteJWKSet(tenantId)
233
+ const {payload} = await jwtVerify(token, jwks, {})
234
+ return payload
235
+ } catch (error) {
236
+ throwSlasTokenValidationError(error.message, 401)
237
+ }
238
+ }
239
+
240
+ const tenantIdRegExp = /^[a-zA-Z]{4}_([0-9]{3}|s[0-9]{2}|stg|dev|prd)$/
241
+ const shortCodeRegExp = /^[a-zA-Z0-9-]+$/
242
+
243
+ /**
244
+ * Handles JWKS (JSON Web Key Set) caching the JWKS response for 2 weeks.
245
+ *
246
+ * @param {object} req Express request object.
247
+ * @param {object} res Express response object.
248
+ * @param {object} options Options for fetching B2C Commerce API JWKS.
249
+ * @param {string} options.shortCode - The Short Code assigned to the realm.
250
+ * @param {string} options.tenantId - The Tenant ID for the ECOM instance.
251
+ * @returns {Promise<*>} Promise with the JWKS data.
252
+ */
253
+ export async function jwksCaching(req, res, options) {
254
+ const {shortCode, tenantId} = options
255
+
256
+ const isValidRequest = tenantIdRegExp.test(tenantId) && shortCodeRegExp.test(shortCode)
257
+ if (!isValidRequest)
258
+ return res
259
+ .status(400)
260
+ .json({error: 'Bad request parameters: Tenant ID or short code is invalid.'})
261
+ try {
262
+ const JWKS_URI = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth/v1/organizations/f_ecom_${tenantId}/oauth2/jwks`
263
+ const response = await fetch(JWKS_URI)
264
+
265
+ if (!response.ok) {
266
+ throw new Error('Request failed with status: ' + response.status)
267
+ }
268
+
269
+ // JWKS rotate every 30 days. For now, cache response for 14 days so that
270
+ // fetches only need to happen twice a month
271
+ res.set('Cache-Control', 'public, max-age=1209600, stale-while-revalidate=86400')
272
+
273
+ return res.json(await response.json())
274
+ } catch (error) {
275
+ res.status(400).json({error: `Error while fetching data: ${error.message}`})
276
+ }
277
+ }
278
+
96
279
  const {handler} = runtime.createHandler(options, (app) => {
97
280
  app.use(express.json()) // To parse JSON payloads
98
281
  app.use(express.urlencoded({extended: true}))
@@ -144,7 +327,7 @@ const {handler} = runtime.createHandler(options, (app) => {
144
327
  sendMagicLinkEmail(
145
328
  req,
146
329
  res,
147
- PASSWORDLESS_LOGIN_LANDING_PATH,
330
+ config.app.login?.passwordless?.landingPath,
148
331
  process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE,
149
332
  redirectUrl
150
333
  )
@@ -161,7 +344,7 @@ const {handler} = runtime.createHandler(options, (app) => {
161
344
  sendMagicLinkEmail(
162
345
  req,
163
346
  res,
164
- RESET_PASSWORD_LANDING_PATH,
347
+ config.app.login?.resetPassword?.landingPath,
165
348
  process.env.MARKETING_CLOUD_RESET_PASSWORD_TEMPLATE
166
349
  )
167
350
  })
@@ -26,8 +26,10 @@ module.exports = {
26
26
  // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer.
27
27
  // Required in 'callback' mode; if missing, passwordless login defaults to 'sms' mode, which requires Marketing Cloud configuration.
28
28
  // If the env var `PASSWORDLESS_LOGIN_CALLBACK_URI` is set, it will override the config value.
29
- callbackURI:
30
- process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback'
29
+ callbackURI:
30
+ process.env.PASSWORDLESS_LOGIN_CALLBACK_URI || '/passwordless-login-callback',
31
+ // The landing path for passwordless login
32
+ landingPath: '/passwordless-login-landing'
31
33
  },
32
34
  social: {
33
35
  // Enables or disables social login for the site. Defaults to: false
@@ -43,7 +45,9 @@ module.exports = {
43
45
  resetPassword: {
44
46
  // The callback URI, which can be an absolute URL (including third-party URIs) or a relative path set up by the developer.
45
47
  // If the env var `RESET_PASSWORD_CALLBACK_URI` is set, it will override the config value.
46
- callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback'
48
+ callbackURI: process.env.RESET_PASSWORD_CALLBACK_URI || '/reset-password-callback',
49
+ // The landing path for reset password
50
+ landingPath: '/reset-password-landing'
47
51
  }
48
52
  },
49
53
  // The default site for your app. This value will be used when a siteRef could not be determined from the url
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/pwa-kit-create-app",
3
- "version": "3.9.0-preview.2",
3
+ "version": "3.9.0-preview.4",
4
4
  "description": "Salesforce's project generator tool",
5
5
  "homepage": "https://github.com/SalesforceCommerceCloud/pwa-kit/tree/develop/packages/pwa-kit-create-app#readme",
6
6
  "bugs": {
@@ -38,13 +38,13 @@
38
38
  "tar": "^6.2.1"
39
39
  },
40
40
  "devDependencies": {
41
- "@salesforce/pwa-kit-dev": "3.9.0-preview.2",
42
- "internal-lib-build": "3.9.0-preview.2",
41
+ "@salesforce/pwa-kit-dev": "3.9.0-preview.4",
42
+ "internal-lib-build": "3.9.0-preview.4",
43
43
  "verdaccio": "^5.22.1"
44
44
  },
45
45
  "engines": {
46
46
  "node": "^16.11.0 || ^18.0.0 || ^20.0.0 || ^22.0.0",
47
47
  "npm": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
48
48
  },
49
- "gitHead": "6965aa2fc067c784660c4bb816e699789ea386c5"
49
+ "gitHead": "1f16c75dcb6939a4edc76bb477888239fa76357b"
50
50
  }
Binary file
Binary file
Binary file
Binary file