diff --git a/packages/template-retail-react-app/app/components/otp-auth/index.jsx b/packages/template-retail-react-app/app/components/otp-auth/index.jsx index 8a5ec20e65..46c96f29d3 100644 --- a/packages/template-retail-react-app/app/components/otp-auth/index.jsx +++ b/packages/template-retail-react-app/app/components/otp-auth/index.jsx @@ -145,6 +145,7 @@ const OtpAuth = ({ resendAttempt: true }) await handleSendEmailOtp(form.getValues('email')) + otpInputs.clear() } catch (error) { setResendTimer(0) await track('/otp-resend-failed', { 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 568de0e4c6..aabfa613a9 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 @@ -52,6 +52,8 @@ const PasskeyRegistrationModal = ({isOpen, onClose}) => { const config = getConfig() const webauthnConfig = config.app.login.passkey const authorizeWebauthnRegistration = useAuthHelper(AuthHelpers.AuthorizeWebauthnRegistration) + const startWebauthnUserRegistration = useAuthHelper(AuthHelpers.StartWebauthnUserRegistration) + const finishWebauthnUserRegistration = useAuthHelper(AuthHelpers.FinishWebauthnUserRegistration) const handleRegisterPasskey = async () => { setIsLoading(true) @@ -82,9 +84,109 @@ 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) => { - // TODO: Implement OTP verification - return {success: true} + setIsLoading(true) + setError(null) + + try { + // Step 1: Start WebAuthn registration + const response = await startWebauthnUserRegistration.mutateAsync({ + user_id: customer.email, + pwd_action_token: code, + ...(passkeyNickname && {nick_name: passkeyNickname}) + }) + + // Step 2: Convert response to WebAuthn PublicKeyCredentialCreationOptions format + const publicKey = window.PublicKeyCredential.parseCreationOptionsFromJSON(response) + + // Step 3: Call navigator.credentials.create() + if (!navigator.credentials || !navigator.credentials.create) { + throw new Error('WebAuthn API not available in this browser') + } + + // navigator.credentials.create() will show a browser/system prompt + // This may appear to hang if the user doesn't interact with the prompt + let credential + try { + credential = await navigator.credentials.create({ + publicKey + }) + } catch (createError) { + // Handle user cancellation or other errors from the WebAuthn API + if (createError.name === 'NotAllowedError' || createError.name === 'AbortError') { + throw new Error('Passkey registration was cancelled or timed out') + } + throw createError + } + + if (!credential) { + throw new Error('Failed to create credential: user cancelled or operation failed') + } + + // Step 4: Convert credential to JSON format before sending to SLAS + // https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/toJSON + let credentialJson + try { + credentialJson = 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 + const clientExtensionResults = credential.getClientExtensionResults?.() || {} + credentialJson = { + type: credential.type, + id: credential.id, + rawId: arrayBufferToBase64Url(credential.rawId), + response: { + attestationObject: arrayBufferToBase64Url( + credential.response.attestationObject + ), + clientDataJSON: arrayBufferToBase64Url(credential.response.clientDataJSON) + }, + ...(Object.keys(clientExtensionResults).length > 0 && {clientExtensionResults}) + } + } + + // Step 5: Finish WebAuthn registration + await finishWebauthnUserRegistration.mutateAsync({ + username: customer.email, + credential: credentialJson, + pwd_action_token: code + }) + + // Step 6: Close OTP modal and main modal on success + setIsOtpAuthOpen(false) + onClose() + + return {success: true} + } catch (err) { + const errorMessage = + err.message || + formatMessage({ + id: 'passkey_registration.modal.error.registration_failed', + defaultMessage: 'Failed to register passkey' + }) + + // Return error result for OTP component to display + return { + success: false, + error: errorMessage + } + } finally { + setIsLoading(false) + } } const resetState = () => { diff --git a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js index fcdefd1c70..9917b320e5 100644 --- a/packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js +++ b/packages/template-retail-react-app/app/components/passkey-registration-modal/index.test.js @@ -17,7 +17,12 @@ const mockUseAuthHelper = jest.fn() jest.mock('@salesforce/commerce-sdk-react', () => ({ ...jest.requireActual('@salesforce/commerce-sdk-react'), - useAuthHelper: (param) => mockUseAuthHelper(param) + useAuthHelper: (param) => mockUseAuthHelper(param), + AuthHelpers: { + AuthorizeWebauthnRegistration: 'AuthorizeWebauthnRegistration', + StartWebauthnUserRegistration: 'StartWebauthnUserRegistration', + FinishWebauthnUserRegistration: 'FinishWebauthnUserRegistration' + } })) // Mock useCurrentCustomer @@ -26,14 +31,22 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-customer', () => ( useCurrentCustomer: () => mockUseCurrentCustomer() })) -// Mock OtpAuth component +// Mock OtpAuth component - expose handleOtpVerification for testing +let otpVerificationHandler = null jest.mock('@salesforce/retail-react-app/app/components/otp-auth', () => { const PropTypes = jest.requireActual('prop-types') - const MockOtpAuth = ({isOpen}) => { + const React = jest.requireActual('react') + const MockOtpAuth = ({isOpen, handleOtpVerification}) => { + React.useEffect(() => { + if (handleOtpVerification) { + otpVerificationHandler = handleOtpVerification + } + }, [handleOtpVerification]) return isOpen ?
OTP Auth Modal
: null } MockOtpAuth.propTypes = { - isOpen: PropTypes.bool + isOpen: PropTypes.bool, + handleOtpVerification: PropTypes.func } return MockOtpAuth }) @@ -52,6 +65,7 @@ describe('PasskeyRegistrationModal', () => { beforeEach(() => { jest.clearAllMocks() + otpVerificationHandler = null mockUseCurrentCustomer.mockReturnValue({ data: mockCustomer }) @@ -59,6 +73,11 @@ describe('PasskeyRegistrationModal', () => { mutateAsync: mockMutateAsync }) + // Mock WebAuthn API + global.navigator.credentials = { + create: jest.fn() + } + // Mock product API calls that may be triggered by components in the provider tree global.server.use( rest.get('*/products*', (req, res, ctx) => { @@ -74,7 +93,7 @@ describe('PasskeyRegistrationModal', () => { }) expect(screen.getByText('Create Passkey')).toBeInTheDocument() - expect(screen.getByText('Passkey Nickname')).toBeInTheDocument() + expect(screen.getByText(/Passkey Nickname/)).toBeInTheDocument() expect( screen.getByPlaceholderText("e.g., 'iPhone', 'Personal Laptop'") ).toBeInTheDocument() @@ -149,7 +168,6 @@ describe('PasskeyRegistrationModal', () => { expect(mockMutateAsync).toHaveBeenCalledWith({ user_id: 'test@example.com', mode: 'callback', - channel_id: 'site-1', callback_uri: 'https://webhook.site/ee47be40-e9fc-4313-8b56-18e4fe819043' }) }) @@ -200,4 +218,427 @@ describe('PasskeyRegistrationModal', () => { expect(registerButton).toBeDisabled() }) }) + + describe('handleOtpVerification', () => { + let mockStartWebauthnRegistration + let mockFinishWebauthnRegistration + let mockAuthorizeWebauthnRegistration + + beforeEach(() => { + // Setup separate mocks for each auth helper + mockStartWebauthnRegistration = jest.fn() + mockFinishWebauthnRegistration = jest.fn() + mockAuthorizeWebauthnRegistration = jest.fn() + + mockUseAuthHelper.mockImplementation((helperType) => { + if (helperType === 'StartWebauthnUserRegistration') { + return {mutateAsync: mockStartWebauthnRegistration} + } + if (helperType === 'FinishWebauthnUserRegistration') { + return {mutateAsync: mockFinishWebauthnRegistration} + } + if (helperType === 'AuthorizeWebauthnRegistration') { + return {mutateAsync: mockAuthorizeWebauthnRegistration} + } + return {mutateAsync: mockMutateAsync} + }) + }) + + test('successfully completes OTP verification and passkey registration flow', async () => { + const otpCode = '12345678' + const mockChallenge = 'dGVzdC1jaGFsbGVuZ2U=' + const mockUserId = 'dGVzdC11c2VyLWlk' + + // Mock startWebauthnUserRegistration response + const mockStartResponse = { + challenge: mockChallenge, + rp: { + name: 'Test RP', + id: 'example.com' + }, + user: { + id: mockUserId, + name: 'test@example.com', + displayName: 'Test User' + }, + pubKeyCredParams: [ + { + type: 'public-key', + alg: -7 + } + ], + authenticatorSelection: { + authenticatorAttachment: 'platform', + userVerification: 'required' + }, + timeout: 60000, + attestation: 'none' + } + + // Mock WebAuthn credential + const mockCredential = { + type: 'public-key', + id: 'test-credential-id', + rawId: new ArrayBuffer(8), + response: { + attestationObject: new ArrayBuffer(16), + clientDataJSON: new ArrayBuffer(16) + } + } + + mockStartWebauthnRegistration.mockResolvedValue(mockStartResponse) + global.navigator.credentials.create.mockResolvedValue(mockCredential) + mockFinishWebauthnRegistration.mockResolvedValue({}) + + const {user} = renderWithProviders( + , + { + wrapperProps: {appConfig: mockConfig.app} + } + ) + + // Open OTP modal first + const registerButton = screen.getByText('Register Passkey') + await user.click(registerButton) + + await waitFor(() => { + expect(screen.getByTestId('otp-auth-modal')).toBeInTheDocument() + }) + + // Wait for handler to be set + await waitFor(() => { + expect(otpVerificationHandler).toBeTruthy() + }) + + // Call handleOtpVerification + const result = await otpVerificationHandler(otpCode) + + // Verify startWebauthnUserRegistration was called correctly + expect(mockStartWebauthnRegistration).toHaveBeenCalledWith({ + user_id: 'test@example.com', + pwd_action_token: otpCode + }) + + // Verify navigator.credentials.create was called + expect(global.navigator.credentials.create).toHaveBeenCalled() + const publicKeyOptions = global.navigator.credentials.create.mock.calls[0][0].publicKey + + // Verify structure and key properties + expect(publicKeyOptions.challenge).toBeDefined() + expect(publicKeyOptions.rp).toMatchObject({ + name: 'Test RP', + id: 'example.com' + }) + expect(publicKeyOptions.user.id).toBeDefined() + expect(Array.isArray(publicKeyOptions.pubKeyCredParams)).toBe(true) + expect(publicKeyOptions.authenticatorSelection).toBeDefined() + expect(typeof publicKeyOptions.timeout).toBe('number') + expect(publicKeyOptions.attestation).toBe('none') + + // Verify finishWebauthnUserRegistration was called correctly + expect(mockFinishWebauthnRegistration).toHaveBeenCalledWith({ + username: 'test@example.com', + credential: expect.objectContaining({ + type: 'public-key', + id: 'test-credential-id' + }), + pwd_action_token: otpCode + }) + + // Verify success result + expect(result).toEqual({success: true}) + + // Verify modals are closed + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled() + }) + }) + + test('includes nickname in startWebauthnUserRegistration when provided', async () => { + const otpCode = '12345678' + const nickname = 'My iPhone' + const mockStartResponse = { + challenge: 'dGVzdA==', + rp: {name: 'Test', id: 'example.com'}, + user: {id: 'dGVzdA==', name: 'test@example.com'}, + pubKeyCredParams: [], + timeout: 60000 + } + + const mockCredential = { + type: 'public-key', + id: 'test-id', + rawId: new ArrayBuffer(8), + response: { + attestationObject: new ArrayBuffer(16), + clientDataJSON: new ArrayBuffer(16) + } + } + + mockStartWebauthnRegistration.mockResolvedValue(mockStartResponse) + global.navigator.credentials.create.mockResolvedValue(mockCredential) + mockFinishWebauthnRegistration.mockResolvedValue({}) + + const {user} = renderWithProviders( + , + { + wrapperProps: {appConfig: mockConfig.app} + } + ) + + // Set nickname + const input = screen.getByPlaceholderText("e.g., 'iPhone', 'Personal Laptop'") + await user.type(input, nickname) + + // Open OTP modal + const registerButton = screen.getByText('Register Passkey') + await user.click(registerButton) + + await waitFor(() => { + expect(otpVerificationHandler).toBeTruthy() + }) + + await otpVerificationHandler(otpCode) + + // Verify nickname was included + expect(mockStartWebauthnRegistration).toHaveBeenCalledWith({ + user_id: 'test@example.com', + pwd_action_token: otpCode, + nick_name: nickname + }) + }) + + test('returns error when startWebauthnUserRegistration fails', async () => { + const otpCode = '12345678' + const errorMessage = 'Failed to start registration' + + mockStartWebauthnRegistration.mockRejectedValue(new Error(errorMessage)) + + const {user} = renderWithProviders( + , + { + wrapperProps: {appConfig: mockConfig.app} + } + ) + + // Open OTP modal + const registerButton = screen.getByText('Register Passkey') + await user.click(registerButton) + + await waitFor(() => { + expect(otpVerificationHandler).toBeTruthy() + }) + + const result = await otpVerificationHandler(otpCode) + + expect(result).toEqual({ + success: false, + error: errorMessage + }) + + // Verify modals are not closed on error + // mockOnClose was called once when opening OTP modal, but not again after error + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + test('returns error when WebAuthn API is not available', async () => { + const otpCode = '12345678' + const mockStartResponse = { + challenge: 'dGVzdA==', + rp: {name: 'Test', id: 'example.com'}, + user: {id: 'dGVzdA==', name: 'test@example.com'}, + pubKeyCredParams: [], + timeout: 60000 + } + + mockStartWebauthnRegistration.mockResolvedValue(mockStartResponse) + // Remove credentials API + delete global.navigator.credentials + + const {user} = renderWithProviders( + , + { + wrapperProps: {appConfig: mockConfig.app} + } + ) + + // Open OTP modal + const registerButton = screen.getByText('Register Passkey') + await user.click(registerButton) + + await waitFor(() => { + expect(otpVerificationHandler).toBeTruthy() + }) + + const result = await otpVerificationHandler(otpCode) + + expect(result).toEqual({ + success: false, + error: 'WebAuthn API not available in this browser' + }) + }) + + test('returns error when user cancels WebAuthn prompt', async () => { + const otpCode = '12345678' + const mockStartResponse = { + challenge: 'dGVzdA==', + rp: {name: 'Test', id: 'example.com'}, + user: {id: 'dGVzdA==', name: 'test@example.com'}, + pubKeyCredParams: [], + timeout: 60000 + } + + const notAllowedError = new Error('User cancelled') + notAllowedError.name = 'NotAllowedError' + + mockStartWebauthnRegistration.mockResolvedValue(mockStartResponse) + global.navigator.credentials.create.mockRejectedValue(notAllowedError) + + const {user} = renderWithProviders( + , + { + wrapperProps: {appConfig: mockConfig.app} + } + ) + + // Open OTP modal + const registerButton = screen.getByText('Register Passkey') + await user.click(registerButton) + + await waitFor(() => { + expect(otpVerificationHandler).toBeTruthy() + }) + + const result = await otpVerificationHandler(otpCode) + + expect(result).toEqual({ + success: false, + error: 'Passkey registration was cancelled or timed out' + }) + }) + + test('returns error when WebAuthn create returns null credential', async () => { + const otpCode = '12345678' + const mockStartResponse = { + challenge: 'dGVzdA==', + rp: {name: 'Test', id: 'example.com'}, + user: {id: 'dGVzdA==', name: 'test@example.com'}, + pubKeyCredParams: [], + timeout: 60000 + } + + mockStartWebauthnRegistration.mockResolvedValue(mockStartResponse) + global.navigator.credentials.create.mockResolvedValue(null) + + const {user} = renderWithProviders( + , + { + wrapperProps: {appConfig: mockConfig.app} + } + ) + + // Open OTP modal + const registerButton = screen.getByText('Register Passkey') + await user.click(registerButton) + + await waitFor(() => { + expect(otpVerificationHandler).toBeTruthy() + }) + + const result = await otpVerificationHandler(otpCode) + + expect(result).toEqual({ + success: false, + error: 'Failed to create credential: user cancelled or operation failed' + }) + }) + + test('returns error when finishWebauthnUserRegistration fails', async () => { + const otpCode = '12345678' + const mockStartResponse = { + challenge: 'dGVzdA==', + rp: {name: 'Test', id: 'example.com'}, + user: {id: 'dGVzdA==', name: 'test@example.com'}, + pubKeyCredParams: [], + timeout: 60000 + } + + const mockCredential = { + type: 'public-key', + id: 'test-id', + rawId: new ArrayBuffer(8), + response: { + attestationObject: new ArrayBuffer(16), + clientDataJSON: new ArrayBuffer(16) + } + } + + const errorMessage = 'Failed to finish registration' + + mockStartWebauthnRegistration.mockResolvedValue(mockStartResponse) + global.navigator.credentials.create.mockResolvedValue(mockCredential) + mockFinishWebauthnRegistration.mockRejectedValue(new Error(errorMessage)) + + const {user} = renderWithProviders( + , + { + wrapperProps: {appConfig: mockConfig.app} + } + ) + + // Open OTP modal + const registerButton = screen.getByText('Register Passkey') + await user.click(registerButton) + + await waitFor(() => { + expect(otpVerificationHandler).toBeTruthy() + }) + + const result = await otpVerificationHandler(otpCode) + + expect(result).toEqual({ + success: false, + error: errorMessage + }) + }) + + test('handles AbortError from WebAuthn API', async () => { + const otpCode = '12345678' + const mockStartResponse = { + challenge: 'dGVzdA==', + rp: {name: 'Test', id: 'example.com'}, + user: {id: 'dGVzdA==', name: 'test@example.com'}, + pubKeyCredParams: [], + timeout: 60000 + } + + const abortError = new Error('Operation aborted') + abortError.name = 'AbortError' + + mockStartWebauthnRegistration.mockResolvedValue(mockStartResponse) + global.navigator.credentials.create.mockRejectedValue(abortError) + + const {user} = renderWithProviders( + , + { + wrapperProps: {appConfig: mockConfig.app} + } + ) + + // Open OTP modal + const registerButton = screen.getByText('Register Passkey') + await user.click(registerButton) + + await waitFor(() => { + expect(otpVerificationHandler).toBeTruthy() + }) + + const result = await otpVerificationHandler(otpCode) + + expect(result).toEqual({ + success: false, + error: 'Passkey registration was cancelled or timed out' + }) + }) + }) }) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js index b9afaac5b2..64f210ab83 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js @@ -252,7 +252,6 @@ export const AuthModal = ({ ) { Promise.all([ window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), - window.PublicKeyCredential.isConditionalMediationAvailable() ]).then((results) => { if (results.every((r) => r === true)) { diff --git a/packages/template-retail-react-app/app/pages/registration/index.jsx b/packages/template-retail-react-app/app/pages/registration/index.jsx index 2c2525d721..20c1bd64ea 100644 --- a/packages/template-retail-react-app/app/pages/registration/index.jsx +++ b/packages/template-retail-react-app/app/pages/registration/index.jsx @@ -68,7 +68,6 @@ const Registration = () => { ) { Promise.all([ window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), - window.PublicKeyCredential.isConditionalMediationAvailable() ]).then((results) => { if (results.every((r) => r === true)) { diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json index f5049e1ab5..bbe644565a 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-GB.json @@ -2999,6 +2999,12 @@ "value": "Failed to authorize passkey registration" } ], + "passkey_registration.modal.error.registration_failed": [ + { + "type": 0, + "value": "Failed to register passkey" + } + ], "passkey_registration.modal.label.nickname": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json index f5049e1ab5..bbe644565a 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-US.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-US.json @@ -2999,6 +2999,12 @@ "value": "Failed to authorize passkey registration" } ], + "passkey_registration.modal.error.registration_failed": [ + { + "type": 0, + "value": "Failed to register passkey" + } + ], "passkey_registration.modal.label.nickname": [ { "type": 0, diff --git a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json index 9b6ff79828..3fa008d037 100644 --- a/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json +++ b/packages/template-retail-react-app/app/static/translations/compiled/en-XA.json @@ -6287,6 +6287,20 @@ "value": "]" } ], + "passkey_registration.modal.error.registration_failed": [ + { + "type": 0, + "value": "[" + }, + { + "type": 0, + "value": "Ƒȧȧīŀḗḗḓ ŧǿǿ řḗḗɠīşŧḗḗř ƥȧȧşşķḗḗẏ" + }, + { + "type": 0, + "value": "]" + } + ], "passkey_registration.modal.label.nickname": [ { "type": 0, diff --git a/packages/template-retail-react-app/translations/en-GB.json b/packages/template-retail-react-app/translations/en-GB.json index 906ca70d62..488588f871 100644 --- a/packages/template-retail-react-app/translations/en-GB.json +++ b/packages/template-retail-react-app/translations/en-GB.json @@ -1240,6 +1240,9 @@ "passkey_registration.modal.error.authorize_failed": { "defaultMessage": "Failed to authorize passkey registration" }, + "passkey_registration.modal.error.registration_failed": { + "defaultMessage": "Failed to register passkey" + }, "passkey_registration.modal.label.nickname": { "defaultMessage": "Passkey Nickname (optional)" }, diff --git a/packages/template-retail-react-app/translations/en-US.json b/packages/template-retail-react-app/translations/en-US.json index 906ca70d62..488588f871 100644 --- a/packages/template-retail-react-app/translations/en-US.json +++ b/packages/template-retail-react-app/translations/en-US.json @@ -1240,6 +1240,9 @@ "passkey_registration.modal.error.authorize_failed": { "defaultMessage": "Failed to authorize passkey registration" }, + "passkey_registration.modal.error.registration_failed": { + "defaultMessage": "Failed to register passkey" + }, "passkey_registration.modal.label.nickname": { "defaultMessage": "Passkey Nickname (optional)" },