diff --git a/packages/commerce-sdk-react/src/auth/index.test.ts b/packages/commerce-sdk-react/src/auth/index.test.ts index 95fbba67dc..c81857a398 100644 --- a/packages/commerce-sdk-react/src/auth/index.test.ts +++ b/packages/commerce-sdk-react/src/auth/index.test.ts @@ -1305,113 +1305,268 @@ describe('Webauthn', () => { } as ShopperLoginTypes.AuthenticatorAssertionResponseJson } - test('authorizeWebauthnRegistration', async () => { - const auth = new Auth(config) - await auth.authorizeWebauthnRegistration({ - user_id: 'test-user-id', - mode: 'test-mode', - channel_id: 'test-channel-id' - }) - - expect((auth as any).client.authorizeWebauthnRegistration).toHaveBeenCalledWith({ - headers: { - Authorization: '' + test.each([ + [ + 'with all parameters specified', + { + user_id: 'user@example.com', + mode: 'email', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + locale: 'en-GB', + code_challenge: 'test-code-challenge', + callback_uri: 'https://example.com/callback', + idp_name: 'customIdp', + hint: 'custom_hint' }, - body: { - user_id: 'test-user-id', - mode: 'test-mode', - channel_id: 'test-channel-id' + { + user_id: 'user@example.com', + mode: 'email', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + locale: 'en-GB', + code_challenge: 'test-code-challenge', + callback_uri: 'https://example.com/callback', + idp_name: 'customIdp', + hint: 'custom_hint' } - }) - }) + ], + [ + 'defaults optional parameters when only required parameters are specified', + { + user_id: 'user@example.com', + mode: 'email' + }, + { + user_id: 'user@example.com', + mode: 'email', + channel_id: config.siteId + } + ] + ])( + 'authorizeWebauthnRegistration %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.authorizeWebauthnRegistration( + input as ShopperLoginTypes.authorizeWebauthnRegistrationBodyType + ) - test('startWebauthnUserRegistration', async () => { - const auth = new Auth(config) - await auth.startWebauthnUserRegistration({ - channel_id: 'test-channel-id', - display_name: 'test-display-name', - nick_name: 'test-nick-name', - client_id: 'test-client-id', - pwd_action_token: 'test-pwd-action-token', - user_id: 'test-user-id' - }) + expect((auth as any).client.authorizeWebauthnRegistration).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) - expect((auth as any).client.startWebauthnUserRegistration).toHaveBeenCalledWith({ - headers: { - Authorization: '' + test.each([ + [ + 'with all parameters specified', + { + user_id: 'user@example.com', + pwd_action_token: 'test-pwd-action-token', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + display_name: 'Test Display Name', + nick_name: 'Test Nick Name' }, - body: { - display_name: 'test-display-name', - nick_name: 'test-nick-name', - client_id: 'test-client-id', - channel_id: 'test-channel-id', + { + user_id: 'user@example.com', pwd_action_token: 'test-pwd-action-token', - user_id: 'test-user-id' + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + display_name: 'Test Display Name', + nick_name: 'Test Nick Name' } - }) - }) + ], + [ + 'defaults optional parameters when only required parameters are specified', + { + user_id: 'user@example.com', + pwd_action_token: 'test-pwd-action-token' + }, + { + user_id: 'user@example.com', + pwd_action_token: 'test-pwd-action-token', + channel_id: config.siteId + } + ] + ])( + 'startWebauthnUserRegistration %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.startWebauthnUserRegistration( + input as ShopperLoginTypes.startWebauthnUserRegistrationBodyType + ) - test('finishWebauthnUserRegistration', async () => { - const auth = new Auth(config) - await auth.finishWebauthnUserRegistration({ - client_id: 'test-client-id', - username: 'test-username', - credential: PUBLIC_KEY_CREDENTIAL_JSON, - channel_id: 'test-channel-id', - pwd_action_token: 'test-pwd-action-token' - }) + expect((auth as any).client.startWebauthnUserRegistration).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) - expect((auth as any).client.finishWebauthnUserRegistration).toHaveBeenCalledWith({ - headers: { - Authorization: '' + test.each([ + [ + 'with all parameters specified', + { + username: 'user@example.com', + credential: PUBLIC_KEY_CREDENTIAL_JSON, + pwd_action_token: 'test-pwd-action-token', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id' }, - body: { - client_id: 'test-client-id', - username: 'test-username', + { + username: 'user@example.com', + credential: PUBLIC_KEY_CREDENTIAL_JSON, + pwd_action_token: 'test-pwd-action-token', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id' + } + ], + [ + 'defaults optional parameters when only required parameters are specified', + { + username: 'user@example.com', credential: PUBLIC_KEY_CREDENTIAL_JSON, - channel_id: 'test-channel-id', pwd_action_token: 'test-pwd-action-token' + }, + { + username: 'user@example.com', + credential: PUBLIC_KEY_CREDENTIAL_JSON, + pwd_action_token: 'test-pwd-action-token', + channel_id: config.siteId, + client_id: config.clientId } - }) - }) + ] + ])( + 'finishWebauthnUserRegistration %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.finishWebauthnUserRegistration( + input as ShopperLoginTypes.RegistrationFinishRequest + ) - test('startWebauthnAuthentication', async () => { - const auth = new Auth(config) - await auth.startWebauthnAuthentication({ - user_id: 'test-user-id', - channel_id: 'test-channel-id', - client_id: 'test-client-id' - }) + expect((auth as any).client.finishWebauthnUserRegistration).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) - expect((auth as any).client.startWebauthnAuthentication).toHaveBeenCalledWith({ - headers: { - Authorization: '' + test.each([ + [ + 'with all parameters specified', + { + user_id: 'user@example.com', + tenant_id: 'tenant-123', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id' }, - body: { - user_id: 'test-user-id', - channel_id: 'test-channel-id', - client_id: 'test-client-id' + { + user_id: 'user@example.com', + tenant_id: 'tenant-123', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id' } - }) - }) + ], + [ + 'defaults optional parameters when empty object is provided', + {}, + { + channel_id: config.siteId, + client_id: config.clientId + } + ] + ])( + 'startWebauthnAuthentication %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.startWebauthnAuthentication( + input as ShopperLoginTypes.startWebauthnAuthenticationBodyType + ) - test('finishWebauthnAuthentication', async () => { - const auth = new Auth(config) - await auth.finishWebauthnAuthentication({ - client_id: 'test-client-id', - channel_id: 'test-channel-id', - credential: PUBLIC_KEY_CREDENTIAL_JSON - }) + expect((auth as any).client.startWebauthnAuthentication).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) - expect((auth as any).client.finishWebauthnAuthentication).toHaveBeenCalledWith({ - headers: { - Authorization: '' + test.each([ + [ + 'with all parameters specified', + { + user_id: 'user@example.com', + email: 'user@example.com', + tenant_id: 'tenant-123', + usid: 'usid-123', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', + credential: PUBLIC_KEY_CREDENTIAL_JSON }, - body: { - client_id: 'test-client-id', - channel_id: 'test-channel-id', + { + user_id: 'user@example.com', + email: 'user@example.com', + tenant_id: 'tenant-123', + usid: 'usid-123', + channel_id: 'custom-channel-id', + client_id: 'custom-client-id', credential: PUBLIC_KEY_CREDENTIAL_JSON } - }) - }) + ], + [ + 'defaults optional parameters when only required parameters are specified', + { + credential: PUBLIC_KEY_CREDENTIAL_JSON + }, + { + channel_id: config.siteId, + client_id: config.clientId, + credential: PUBLIC_KEY_CREDENTIAL_JSON + } + ] + ])( + 'finishWebauthnAuthentication %s', + async ( + _, + input: Partial, + expectedBody: Partial + ) => { + const auth = new Auth(config) + await auth.finishWebauthnAuthentication( + input as ShopperLoginTypes.AuthenticateFinishRequest + ) + + expect((auth as any).client.finishWebauthnAuthentication).toHaveBeenCalledWith({ + headers: { + Authorization: '' + }, + body: expectedBody + }) + } + ) }) diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts index 0ee699ab93..295860910c 100644 --- a/packages/commerce-sdk-react/src/auth/index.ts +++ b/packages/commerce-sdk-react/src/auth/index.ts @@ -1387,9 +1387,7 @@ class Auth { if (authHeader) { options.headers.Authorization = authHeader } - // TODO: no code verifier needed with the fix blair has made, delete this when the fix has been merged to production - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + const res = await this.client.resetPassword(options) return res } @@ -1532,12 +1530,14 @@ class Auth { // Required params client_id: parameters.client_id || slasClient.clientConfig.parameters.clientId, channel_id: parameters.channel_id || slasClient.clientConfig.parameters.siteId, - user_id: parameters.user_id, // Optional params + ...(parameters.user_id && {user_id: parameters.user_id}), ...(parameters.tenant_id && {tenant_id: parameters.tenant_id}) } } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore TODO: user_id is optional, but commerce-sdk-isomorphic expects it to be required. Remove this comment after commerce-sdk-isomorphic is updated. return await slasClient.startWebauthnAuthentication(options) } diff --git a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx index aabfa613a9..a9d007182a 100644 --- a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.jsx @@ -32,6 +32,7 @@ import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-cur // Utils import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/utils' // SDK import {AuthHelpers, useAuthHelper} from '@salesforce/commerce-sdk-react' @@ -84,19 +85,6 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { } } - /** - * Convert ArrayBuffer to base64url string - */ - const arrayBufferToBase64Url = (buffer) => { - const bytes = new Uint8Array(buffer) - let binary = '' - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]) - } - const base64 = btoa(binary) - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') - } - const handleOtpVerification = async (code) => { setIsLoading(true) setError(null) diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.js new file mode 100644 index 0000000000..51bc1f747f --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {useAuthHelper, AuthHelpers, useUsid} from '@salesforce/commerce-sdk-react' +import {arrayBufferToBase64Url} from '@salesforce/retail-react-app/app/utils/utils' + +/** + * This hook provides commerce-react-sdk hooks to simplify the passkey login flow. + */ +export const usePasskeyLogin = () => { + const startWebauthnAuthentication = useAuthHelper(AuthHelpers.StartWebauthnAuthentication) + const finishWebauthnAuthentication = useAuthHelper(AuthHelpers.FinishWebauthnAuthentication) + const {usid} = useUsid() + + const loginWithPasskey = async () => { + const config = getConfig() + + // Check if passkey is enabled in config + if (!config?.app?.login?.passkey?.enabled) { + return + } + + // Availability of window.PublicKeyCredential means WebAuthn is supported in this browser + if ( + !window.PublicKeyCredential || + !window.PublicKeyCredential.isConditionalMediationAvailable + ) { + return + } + + // Check if conditional mediation is available. Conditional mediation is a feature of the WebAuthn API that allows passkeys to appear in the browser's standard autofill suggestions, alongside saved passwords. This allows users to sign in with a passkey using the standard username input field, rather than clicking a dedicated passkey login button. + // https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/isConditionalMediationAvailable + const isCMA = await window.PublicKeyCredential.isConditionalMediationAvailable() + if (!isCMA) { + return + } + + const startWebauthnAuthenticationResponse = await startWebauthnAuthentication.mutateAsync( + {} + ) + + // Transform response for WebAuthn API to send to navigator.credentials.get() + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/parseRequestOptionsFromJSON_static + const options = window.PublicKeyCredential.parseRequestOptionsFromJSON( + startWebauthnAuthenticationResponse.publicKey + ) + + // Get passkey credential from browser + // https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get + let credential + try { + credential = await navigator.credentials.get({ + publicKey: options, + mediation: 'conditional' + }) + } catch (error) { + // NotAllowedError is thrown when the user cancels the passkey login + // We return early in this case to avoid showing an error to the user + if (error.name == 'NotAllowedError') { + return + } + throw error + } + + // Encode credential before sending to SLAS + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/toJSON + let encodedCredential + try { + encodedCredential = credential.toJSON() + } catch (error) { + // Fallback to manual encoding if toJSON() fails. + // Some passkey providers (e.g., 1Password) may not support the toJSON() method and return an error. + // In this case, we manually encode the credential. + encodedCredential = { + id: credential.id, + rawId: arrayBufferToBase64Url(credential.rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + authenticatorData: arrayBufferToBase64Url( + credential.response.authenticatorData + ), + clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON), + signature: arrayBufferToBase64Url(credential.response.signature), + userHandle: arrayBufferToBase64Url(credential.response.userHandle) + } + } + } + + await finishWebauthnAuthentication.mutateAsync({ + credential: encodedCredential, + usid + }) + return + } + + return {loginWithPasskey} +} diff --git a/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js new file mode 100644 index 0000000000..3b12bba031 --- /dev/null +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.test.js @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react' +import {rest} from 'msw' +import {fireEvent, screen, waitFor} from '@testing-library/react' +import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils' +import {usePasskeyLogin} from '@salesforce/retail-react-app/app/hooks/use-passkey-login' +import mockConfig from '@salesforce/retail-react-app/config/mocks/default' +import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {registerUserToken} from '@salesforce/retail-react-app/app/utils/test-utils' + +const mockCredential = { + id: 'test-credential-id', + rawId: new ArrayBuffer(8), + type: 'public-key', + getClientExtensionResults: () => ({}), + toJSON: () => ({ + id: 'test-credential-id', + rawId: 'AAAAAAAAAAA', + type: 'public-key', + clientExtensionResults: {}, + response: { + authenticatorData: 'AAAAAAAAAAA', + clientDataJSON: 'AAAAAAAAAAA', + signature: 'AAAAAAAAAAA', + userHandle: 'AAAAAAAAAAA' + } + }), + response: { + authenticatorData: new ArrayBuffer(8), + clientDataJSON: new ArrayBuffer(8), + signature: new ArrayBuffer(8), + userHandle: new ArrayBuffer(8) + } +} + +const mockStartWebauthnAuthenticationResponse = { + publicKey: { + challenge: 'DZdUeRgEm5m1D8Fqp8pzZZesdHkf1Pqoe-MqCA8gVw8', + timeout: 60000, + rpId: 'localhost', + allowCredentials: [ + { + id: 'test-credential-id', + type: 'public-key', + transports: [] + } + ] + } +} + +const mockFinishWebauthnAuthenticationResponse = { + tokenResponse: { + access_token: registerUserToken, + customer_id: 'customerid', + refresh_token: 'testrefeshtoken', + usid: 'testusid', + enc_user_id: 'testEncUserId', + id_token: 'testIdToken' + } +} + +// Mock getConfig to enable passkey +jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ + getConfig: jest.fn() +})) + +// Mock WebAuthn APIs +const mockGetCredentials = jest.fn() + +// Mock PublicKeyCredential static methods +const mockIsConditionalMediationAvailable = jest.fn() +const mockParseRequestOptionsFromJSON = jest.fn() + +const MockComponent = () => { + const {loginWithPasskey} = usePasskeyLogin() + return ( +
+
+ ) +} + +describe('usePasskeyLogin', () => { + beforeEach(() => { + jest.clearAllMocks() + + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json(mockStartWebauthnAuthenticationResponse) + ) + }), + rest.post('*/oauth2/webauthn/authenticate/finish', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json(mockFinishWebauthnAuthenticationResponse) + ) + }) + ) + + getConfig.mockReturnValue(mockConfig) + + // Mock PublicKeyCredential with static methods + const mockPublicKeyCredential = { + isConditionalMediationAvailable: mockIsConditionalMediationAvailable, + parseRequestOptionsFromJSON: mockParseRequestOptionsFromJSON + } + global.window.PublicKeyCredential = mockPublicKeyCredential + global.PublicKeyCredential = mockPublicKeyCredential + + // Default mock implementations for PublicKeyCredential static methods + mockIsConditionalMediationAvailable.mockResolvedValue(true) + // parseRequestOptionsFromJSON should return parsed options, not a credential + mockParseRequestOptionsFromJSON.mockReturnValue({ + challenge: mockStartWebauthnAuthenticationResponse.publicKey.challenge, + timeout: mockStartWebauthnAuthenticationResponse.publicKey.timeout, + rpId: mockStartWebauthnAuthenticationResponse.publicKey.rpId + }) + + // Mock navigator.credentials.get + global.navigator.credentials = { + get: mockGetCredentials + } + + // Mock navigator.credentials.get to return a mock credential + mockGetCredentials.mockResolvedValue(mockCredential) + }) + + test('calls navigator.credentials.get with the correct parameters when all conditions are met', async () => { + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + // Check that credentials.get is called with the correct parameters + await waitFor(() => { + expect(mockGetCredentials).toHaveBeenCalledWith({ + publicKey: expect.objectContaining({ + challenge: expect.any(String), + timeout: expect.any(Number), + rpId: expect.any(String) + }), + mediation: 'conditional' + }) + }) + }) + + test('does not call navigator.credentials.get when passkey is not enabled', async () => { + getConfig.mockReturnValue({ + ...mockConfig, + app: { + ...mockConfig.app, + login: { + ...mockConfig.app.login, + passkey: { + enabled: false + } + } + } + }) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + expect(mockGetCredentials).not.toHaveBeenCalled() + }) + + test('does not start passkey login when PublicKeyCredential is not available', async () => { + delete global.window.PublicKeyCredential + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + expect(mockGetCredentials).not.toHaveBeenCalled() + }) + + test('does not start passkey login when conditional mediation is not available', async () => { + mockIsConditionalMediationAvailable.mockResolvedValue(false) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + await waitFor(() => { + expect(mockIsConditionalMediationAvailable).toHaveBeenCalled() + }) + + expect(mockGetCredentials).not.toHaveBeenCalled() + }) + + test('falls back to manual encoding when toJSON() is not supported', async () => { + // Create a credential mock where toJSON() throws an error (e.g., 1Password) + const credentialWithoutToJSON = { + ...mockCredential, + toJSON: jest.fn(() => { + throw new Error('toJSON is not supported') + }) + } + + // Reset and set the mock for this specific test to ensure it returns the credential + mockGetCredentials.mockResolvedValue(credentialWithoutToJSON) + + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(200), + ctx.json(mockStartWebauthnAuthenticationResponse) + ) + }), + rest.post('*/oauth2/webauthn/authenticate/finish', async (req, res, ctx) => { + const body = await req.json() + // Assert: credential is still manually encoded when toJSON() is not supported + expect(body).toEqual( + expect.objectContaining({ + credential: expect.objectContaining({ + id: 'test-credential-id', + rawId: expect.any(String), + type: 'public-key', + clientExtensionResults: {}, + response: expect.objectContaining({ + authenticatorData: expect.any(String), + clientDataJSON: expect.any(String), + signature: expect.any(String), + userHandle: expect.any(String) + }) + }), + // usid used in the test environment + usid: '8e883973-68eb-41fe-a3c5-756232652ff5' + }) + ) + return res( + ctx.delay(0), + ctx.status(200), + ctx.json(mockFinishWebauthnAuthenticationResponse) + ) + }) + ) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + await waitFor(() => { + expect(mockGetCredentials).toHaveBeenCalled() + }) + }) + + test('returns early without error when NotAllowedError is thrown from navigator.credentials.get', async () => { + // Create a NotAllowedError (typically thrown when user cancels passkey login) + const notAllowedError = new Error('User cancelled') + notAllowedError.name = 'NotAllowedError' + + // Mock navigator.credentials.get to throw NotAllowedError + mockGetCredentials.mockRejectedValue(notAllowedError) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + + // Click the button - should not throw an error even though NotAllowedError is thrown + fireEvent.click(trigger) + + // Wait for navigator.credentials.get to be called + await waitFor(() => { + expect(mockGetCredentials).toHaveBeenCalled() + }) + + // Verify that no error message is displayed + expect(screen.queryByText('Something went wrong. Try again!')).not.toBeInTheDocument() + }) +}) diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx index 01472ac64b..9867d5e5fd 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx @@ -46,6 +46,7 @@ import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origi import {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' +import {usePasskeyLogin} from '@salesforce/retail-react-app/app/hooks/use-passkey-login' import { API_ERROR_MESSAGE, FEATURE_UNAVAILABLE_ERROR_MESSAGE, @@ -63,6 +64,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless) const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket') const mergeBasket = useShopperBasketsMutation('mergeBasket') + const {loginWithPasskey} = usePasskeyLogin() const {step, STEPS, goToStep, goToNextStep} = useCheckout() @@ -79,7 +81,8 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id const [authModalView, setAuthModalView] = useState(PASSWORD_VIEW) const authModal = useAuthModal(authModalView) - const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI + const config = getConfig() + const passwordlessConfigCallback = config.app.login?.passwordless?.callbackURI const callbackURL = isAbsoluteURL(passwordlessConfigCallback) ? passwordlessConfigCallback : `${appOrigin}${getEnvBasePath()}${passwordlessConfigCallback}` @@ -111,15 +114,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id }) } else { await login.mutateAsync({username: data.email, password: data.password}) - - const hasBasketItem = basket.productItems?.length > 0 - if (hasBasketItem) { - mergeBasket.mutate({ - parameters: { - createDestinationBasket: true - } - }) - } + handleMergeBasket() } goToNextStep() } catch (error) { @@ -151,12 +146,38 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id authModal.onOpen() } + const handleMergeBasket = () => { + const hasBasketItem = basket.productItems?.length > 0 + if (hasBasketItem) { + mergeBasket.mutate({ + parameters: { + createDestinationBasket: true + } + }) + } + } + useEffect(() => { if (!showPasswordField) { form.unregister('password') } }, [showPasswordField]) + useEffect(() => { + const handlePasskeyLogin = async () => { + try { + await loginWithPasskey() + handleMergeBasket() + } catch (error) { + setError(formatMessage(API_ERROR_MESSAGE)) + } + } + + if (!customer.isRegistered) { + handlePasskeyLogin() + } + }, [customer.isRegistered]) + const onPasswordlessLoginClick = async (e) => { const isValid = await form.trigger('email') const domForm = e.target.closest('form') diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js index 486c67ebd1..7367bd11d7 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js @@ -67,6 +67,26 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => { } }) +const mockLoginWithPasskey = jest.fn().mockResolvedValue(undefined) + +jest.mock('@salesforce/retail-react-app/app/hooks/use-passkey-login', () => { + return { + __esModule: true, + usePasskeyLogin: jest.fn(() => ({ + loginWithPasskey: mockLoginWithPasskey + })) + } +}) + +const mockUseCurrentCustomer = jest.fn(() => ({ + data: { + isRegistered: false + } +})) +jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ({ + useCurrentCustomer: () => mockUseCurrentCustomer() +})) + afterEach(() => { jest.resetModules() jest.restoreAllMocks() @@ -376,3 +396,49 @@ describe('navigation based on shipment context', () => { expect(mockGoToStep).not.toHaveBeenCalled() }) }) + +describe('passkey login', () => { + beforeEach(() => { + jest.clearAllMocks() + // Default to guest user (not registered) + mockUseCurrentCustomer.mockReturnValue({ + data: { + isRegistered: false + } + }) + }) + + test('calls loginWithPasskey on component render', async () => { + renderWithProviders() + + await waitFor(() => { + expect(mockLoginWithPasskey).toHaveBeenCalled() + }) + }) + + test('sets error when loginWithPasskey fails', async () => { + mockLoginWithPasskey.mockRejectedValue(new Error('Passkey authentication failed')) + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('Something went wrong. Try again!')).toBeInTheDocument() + }) + }) + + test('does not call loginWithPasskey when customer is registered', async () => { + // Mock registered customer + mockUseCurrentCustomer.mockReturnValue({ + data: { + isRegistered: true, + email: 'test@example.com' + } + }) + + renderWithProviders() + + // Wait a bit to ensure useEffect has run + await waitFor(() => { + expect(mockLoginWithPasskey).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/template-retail-react-app/app/utils/test-utils.js b/packages/template-retail-react-app/app/utils/test-utils.js index 8604f65379..8915400a5d 100644 --- a/packages/template-retail-react-app/app/utils/test-utils.js +++ b/packages/template-retail-react-app/app/utils/test-utils.js @@ -24,7 +24,8 @@ import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import { CurrencyProvider, MultiSiteProvider, - StoreLocatorProvider + StoreLocatorProvider, + PasskeyRegistrationProvider } from '@salesforce/retail-react-app/app/contexts' import {createUrlTemplate} from '@salesforce/retail-react-app/app/utils/url' @@ -164,7 +165,9 @@ export const TestProviders = ({ - {children} + + {children} + diff --git a/packages/template-retail-react-app/app/utils/utils.js b/packages/template-retail-react-app/app/utils/utils.js index 4d1f526d0d..09ff5287c0 100644 --- a/packages/template-retail-react-app/app/utils/utils.js +++ b/packages/template-retail-react-app/app/utils/utils.js @@ -6,6 +6,7 @@ */ import {getEnvBasePath} from '@salesforce/pwa-kit-runtime/utils/ssr-namespace-paths' +import {encode as base64Encode} from 'base64-arraybuffer' /** * Call requestIdleCallback in supported browsers. @@ -222,3 +223,16 @@ export const buildRedirectURI = (appOrigin = '', redirectPath = '') => { return '' } } + +/** + * Converts an ArrayBuffer or Uint8Array to a base64url-encoded string. + * Base64url encoding is URL-safe (uses '-' and '_' instead of '+' and '/', and omits padding). + * + * @param {ArrayBuffer|Uint8Array} input - The buffer to encode + * @returns {string} Base64url-encoded string + */ +export const arrayBufferToBase64Url = (input) => { + const uint8array = new Uint8Array(input) + const base64 = base64Encode(uint8array.buffer) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} diff --git a/packages/template-retail-react-app/app/utils/utils.test.js b/packages/template-retail-react-app/app/utils/utils.test.js index 81fc4ffb4e..1ca2e5a65f 100644 --- a/packages/template-retail-react-app/app/utils/utils.test.js +++ b/packages/template-retail-react-app/app/utils/utils.test.js @@ -203,3 +203,39 @@ describe('buildRedirectURI', function () { expect(result).toBe('') }) }) + +describe('arrayBufferToBase64Url', () => { + test.each([ + ['empty ArrayBuffer', new ArrayBuffer(0), ''], + ['single byte', new Uint8Array([65]).buffer, 'QQ'], + ['multiple bytes', new Uint8Array([72, 101, 108, 108, 111]).buffer, 'SGVsbG8'], + ['bytes with padding', new Uint8Array([77, 97, 110]).buffer, 'TWFu'], + ['binary data', new Uint8Array([0, 1, 2, 255, 254, 253]).buffer, 'AAEC__79'] + ])('converts %s to base64url', (_, input, expected) => { + const result = utils.arrayBufferToBase64Url(input) + expect(result).toBe(expected) + }) + + test.each([ + ['Uint8Array', new Uint8Array([72, 101, 108, 108, 111])], + ['ArrayBuffer', new Uint8Array([72, 101, 108, 108, 111]).buffer] + ])('accepts %s input type', (_, input) => { + const result = utils.arrayBufferToBase64Url(input) + expect(result).toBe('SGVsbG8') + }) + + test('produces URL-safe base64 (no +, /, or = characters)', () => { + // Use data that would produce +, /, or = in standard base64 + const input = new Uint8Array([251, 239, 191]) // Produces base64 with + and / + const result = utils.arrayBufferToBase64Url(input) + expect(result).not.toMatch(/[+/=]/) + expect(result).toMatch(/^[A-Za-z0-9_-]*$/) + }) + + test('handles large buffers', () => { + const largeArray = new Uint8Array(1000).fill(65) // 1000 'A' characters + const result = utils.arrayBufferToBase64Url(largeArray) + expect(result.length).toBeGreaterThan(0) + expect(result).not.toMatch(/[+/=]/) + }) +}) diff --git a/packages/template-retail-react-app/package.json b/packages/template-retail-react-app/package.json index 13075a4311..40cd9cc937 100644 --- a/packages/template-retail-react-app/package.json +++ b/packages/template-retail-react-app/package.json @@ -101,11 +101,11 @@ "bundlesize": [ { "path": "build/main.js", - "maxSize": "86 kB" + "maxSize": "90 kB" }, { "path": "build/vendor.js", - "maxSize": "363 kB" + "maxSize": "365 kB" } ] }