diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index b0523d6f60..0a4e6bdd2c 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -1,4 +1,5 @@ ## v5.1.0-dev +- Add custom fetch for `authorizePasswordless` when `turnstileResponse` is present (Cloudflare Turnstile support). The commerce-sdk-isomorphic helper does not forward the Turnstile token; this change performs the request via custom fetch so the token reaches the BFF/MRT for server-side verification via Cloudflare Siteverify. - Add Node 24 support. Drop Node 16 support. [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) - Add Shopper Consents API support [#3674](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3674) diff --git a/packages/commerce-sdk-react/src/auth/index.test.ts b/packages/commerce-sdk-react/src/auth/index.test.ts index d8331ea5a2..083e062f6f 100644 --- a/packages/commerce-sdk-react/src/auth/index.test.ts +++ b/packages/commerce-sdk-react/src/auth/index.test.ts @@ -989,7 +989,7 @@ describe('Auth', () => { const mockErrorResponse = { status: 400, - json: jest.fn().mockResolvedValue({message: 'Invalid request'}) + text: jest.fn().mockResolvedValue(JSON.stringify({message: 'Invalid request'})) } ;(helpers.authorizePasswordless as jest.Mock).mockResolvedValueOnce(mockErrorResponse) @@ -998,6 +998,52 @@ describe('Auth', () => { ) }) + test('authorizePasswordless with turnstileResponse uses custom fetch instead of helper', async () => { + const mockFetch = jest + .fn() + .mockResolvedValue({status: 200, text: () => Promise.resolve('')}) + global.fetch = mockFetch + + const auth = new Auth(configSLASPrivate) + // @ts-expect-error private method + auth.set('usid', 'test-usid') + + await auth.authorizePasswordless({ + userid: 'user@example.com', + turnstileResponse: 'turnstile-token-123' + }) + + expect(helpers.authorizePasswordless).not.toHaveBeenCalled() + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, options] = mockFetch.mock.calls[0] + expect(url).toBe( + 'proxy/shopper/auth/v1/organizations/organizationId/oauth2/passwordless/login' + ) + expect(options.method).toBe('POST') + expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded') + const body = new URLSearchParams(options.body) + expect(body.get('user_id')).toBe('user@example.com') + expect(body.get('turnstileResponse')).toBe('turnstile-token-123') + expect(body.get('usid')).toBe('test-usid') + }) + + test('authorizePasswordless with turnstileResponse throws on non-200 fetch response', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + status: 404, + text: () => Promise.resolve('') + }) + global.fetch = mockFetch + + const auth = new Auth(configSLASPrivate) + + await expect( + auth.authorizePasswordless({ + userid: 'guest@example.com', + turnstileResponse: 'token' + }) + ).rejects.toThrow('404') + }) + test('getPasswordLessAccessToken calls isomorphic getPasswordLessAccessToken', async () => { const auth = new Auth(config) await auth.getPasswordLessAccessToken({pwdlessLoginToken: '12345678'}) diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index a9712a9373..f38962d2ae 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -103,6 +103,8 @@ type AuthorizePasswordlessParams = { last_name?: string email?: string phone_number?: string + /** Cloudflare Turnstile response token for bot protection. Backend/MRT should verify via Siteverify and strip before forwarding to SLAS. */ + turnstileResponse?: string } type GetPasswordLessAccessTokenParams = { @@ -284,14 +286,18 @@ class Auth { | undefined private hybridAuthEnabled: boolean + /** Base URL for SLAS client (same as proxy passed to ShopperLogin). Used for custom fetch when turnstileResponse is present. */ + private slasClientBaseUrl: string constructor(config: AuthConfig) { // Special proxy endpoint for injecting SLAS private client secret. // We prioritize config.privateClientProxyEndpoint since that allows us to use the new envBasePath feature - this.client = new ShopperLogin({ - proxy: config.enablePWAKitPrivateClient + this.slasClientBaseUrl = + config.enablePWAKitPrivateClient && config.privateClientProxyEndpoint ? config.privateClientProxyEndpoint - : config.proxy, + : config.proxy + this.client = new ShopperLogin({ + proxy: this.slasClientBaseUrl, headers: config.headers || {}, parameters: { clientId: config.clientId, @@ -1276,6 +1282,8 @@ class Auth { /** * A wrapper method for commerce-sdk-isomorphic helper: authorizePasswordless. + * When turnstileResponse is provided, we perform the request ourselves so the token + * is included in the body (commerce-sdk-isomorphic helper does not forward turnstileResponse). */ async authorizePasswordless(parameters: AuthorizePasswordlessParams) { const usid = this.get('usid') @@ -1284,34 +1292,102 @@ class Auth { const mode = parameters.mode || 'callback' const callbackURI = parameters.callbackURI || this.passwordlessLoginCallbackURI - const res = await helpers.authorizePasswordless({ - slasClient: this.client, - credentials: { - clientSecret: this.clientSecret - }, - parameters: { - ...(callbackURI && {callbackURI}), - ...(usid && {usid}), - ...(parameters.locale && {locale: parameters.locale}), - userid: parameters.userid, - mode, - ...(parameters.register_customer !== undefined && { - registerCustomer: - typeof parameters.register_customer === 'boolean' - ? parameters.register_customer - : parameters.register_customer === 'true' - ? true - : false - }), - ...(parameters.last_name && {lastName: parameters.last_name}), - ...(parameters.email && {email: parameters.email}), - ...(parameters.first_name && {firstName: parameters.first_name}), - ...(parameters.phone_number && {phoneNumber: parameters.phone_number}) + const {turnstileResponse, ...restParams} = parameters + + let res: Response + + if (turnstileResponse) { + // commerce-sdk-isomorphic helper does not include turnstileResponse in the POST body. + // Perform the request ourselves so the Turnstile token reaches the server. + const clientConfig = ( + this.client as { + clientConfig?: { + parameters?: {organizationId?: string; siteId?: string} + fetchOptions?: RequestInit + } + } + ).clientConfig + const organizationId = clientConfig?.parameters?.organizationId + const channelId = clientConfig?.parameters?.siteId + if (!organizationId || !channelId) { + throw new Error('Missing organizationId or siteId for passwordless login') } - }) + // Use SLAS proxy base URL (same-origin) so the request is allowed by CSP and reaches the app server + const url = `${this.slasClientBaseUrl}/shopper/auth/v1/organizations/${organizationId}/oauth2/passwordless/login` + const bodyEntries: Array<[string, string]> = [ + ['user_id', restParams.userid], + ['mode', mode], + ['channel_id', channelId] + ] + if (restParams.locale) bodyEntries.push(['locale', restParams.locale]) + if (usid) bodyEntries.push(['usid', usid]) + if (callbackURI) bodyEntries.push(['callback_uri', callbackURI]) + if (restParams.register_customer !== undefined) { + const val = + typeof restParams.register_customer === 'boolean' + ? restParams.register_customer + : restParams.register_customer === 'true' + bodyEntries.push(['register_customer', val ? 'true' : 'false']) + } + if (restParams.last_name) bodyEntries.push(['last_name', restParams.last_name]) + if (restParams.email) bodyEntries.push(['email', restParams.email]) + if (restParams.first_name) bodyEntries.push(['first_name', restParams.first_name]) + if (restParams.phone_number) bodyEntries.push(['phone_number', restParams.phone_number]) + bodyEntries.push(['turnstileResponse', turnstileResponse]) + + const body = new URLSearchParams(bodyEntries).toString() + const fetchOptions = clientConfig?.fetchOptions ?? {} + res = await fetch(url, { + ...fetchOptions, + method: 'POST', + headers: { + ...(fetchOptions.headers as Record), + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body + }) + } else { + res = await helpers.authorizePasswordless({ + slasClient: this.client, + credentials: { + clientSecret: this.clientSecret + }, + parameters: { + ...(callbackURI && {callbackURI}), + ...(usid && {usid}), + ...(restParams.locale && {locale: restParams.locale}), + userid: restParams.userid, + mode, + ...(restParams.register_customer !== undefined && { + registerCustomer: + typeof restParams.register_customer === 'boolean' + ? restParams.register_customer + : restParams.register_customer === 'true' + ? true + : false + }), + ...(restParams.last_name && {lastName: restParams.last_name}), + ...(restParams.email && {email: restParams.email}), + ...(restParams.first_name && {firstName: restParams.first_name}), + ...(restParams.phone_number && {phoneNumber: restParams.phone_number}) + } + }) + } + if (res && res.status !== 200) { - const errorData = await res.json() - throw new Error(`${res.status} ${String(errorData.message)}`) + let errorMessage = '' + try { + const text = await res.text() + if (text.trim()) { + const errorData = JSON.parse(text) as {message?: string} + errorMessage = String(errorData?.message ?? '').trim() + } + } catch { + // Empty or invalid JSON body (e.g. 404 for guest user) + } + throw new Error( + [res.status, errorMessage].filter(Boolean).join(' ') || String(res.status) + ) } return res } diff --git a/packages/template-retail-react-app/CHANGELOG.md b/packages/template-retail-react-app/CHANGELOG.md index f567f4438e..70e15d03ae 100644 --- a/packages/template-retail-react-app/CHANGELOG.md +++ b/packages/template-retail-react-app/CHANGELOG.md @@ -1,4 +1,5 @@ ## v9.1.0-dev +- Increase vendor.js bundlesize limit to 367 kB - Add Node 24 support. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652) - [Feature] Subscribe to marketing communications. Email capture component updated in footer section to use Shopper Consents API. [#3674](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3674) - [Bugfix] Fix for custom billing address as returning shoppers in 1CC [#3693](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3693) diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index 9f133f8039..6f8538e1ad 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -105,7 +105,7 @@ }, { "path": "build/vendor.js", - "maxSize": "366 kB" + "maxSize": "367 kB" } ] }