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 f73d856bc9..d1b0c555a1 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 @@ -75,7 +75,7 @@ export const AuthModal = ({ const customerId = useCustomerId() const {isRegistered, customerType} = useCustomerType() const prevAuthType = usePrevious(customerType) - const {loginWithPasskey} = usePasskeyLogin() + const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin() const customer = useCustomer( {parameters: {customerId}}, {enabled: !!customerId && isRegistered} @@ -242,6 +242,11 @@ export const AuthModal = ({ form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) }) } + + // Cleanup: abort passkey login when modal closes or component unmounts + return () => { + abortPasskeyLogin() + } }, [isOpen]) // Auto-focus the first field in each form view @@ -270,8 +275,7 @@ export const AuthModal = ({ const isNowRegistered = (isOpen || isOtpAuthOpen) && isRegistered && (loggingIn || registering) // If the customer changed, but it's not because they logged in or registered. Do nothing. - // Also ensure that the customer data is loaded. - if (!isNowRegistered || !customer.data) { + if (!isNowRegistered) { return } @@ -292,7 +296,7 @@ export const AuthModal = ({ id: 'auth_modal.info.welcome_user' }, { - name: customer.data?.firstName || '' + name: customer.data?.firstName || 'back' } )}`, description: `${formatMessage({ @@ -313,7 +317,7 @@ export const AuthModal = ({ // Execute action to be performed on successful registration onRegistrationSuccess() } - }, [isRegistered, customer.data]) + }, [isRegistered]) const onBackToSignInClick = () => initialView === PASSWORD_VIEW ? onClose() : setCurrentView(LOGIN_VIEW) diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js index f6629156b1..be48968b9d 100644 --- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js +++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js @@ -819,6 +819,122 @@ describe('Passkey login', () => { }) }) + test('Passkey prompt is aborted when modal is closed', async () => { + // Capture the abort signal passed to credentials.get + let capturedSignal = null + + // Mock credentials.get to capture the abort signal and stay pending + mockCredentialsGet.mockImplementation(({signal}) => { + capturedSignal = signal + return new Promise(() => { + // Never resolve - simulates passkey prompt staying open + }) + }) + + const {user} = renderWithProviders(, { + wrapperProps: { + bypassAuth: false + } + }) + + // Open the modal + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) + + // Wait for passkey conditional mediation to start and capture the signal + await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + signal: expect.any(AbortSignal) + }) + ) + expect(capturedSignal).not.toBeNull() + }) + + // Verify signal is not yet aborted + expect(capturedSignal.aborted).toBe(false) + + // Close the modal by clicking the close button + const closeButton = screen.getByLabelText(/close login form/i) + await user.click(closeButton) + + // Verify the signal was aborted when modal closed + expect(capturedSignal.aborted).toBe(true) + }) + + test('Passkey prompt is aborted when user logs in', async () => { + // This test verifies that when the user logs in while the passkey + // prompt is pending, the modal closes (login succeeds) and the passkey flow is + // aborted via the cleanup that runs when the modal closes. + let capturedSignal = null + + mockCredentialsGet.mockImplementation(({signal}) => { + expect(signal).toBeInstanceOf(AbortSignal) + capturedSignal = signal + return new Promise(() => { + // Never resolve - simulates passkey prompt staying open + }) + }) + + // Successful email/password login + global.server.use( + rest.post('*/oauth2/token', (req, res, ctx) => + res( + ctx.delay(0), + ctx.json({ + customer_id: 'customerid_1', + access_token: registerUserToken, + refresh_token: 'testrefeshtoken_1', + usid: 'testusid_1', + enc_user_id: 'testEncUserId_1', + id_token: 'testIdToken_1' + }) + ) + ), + rest.post('*/baskets/actions/merge', (req, res, ctx) => { + return res(ctx.delay(0), ctx.json(mockMergedBasket)) + }) + ) + + const {user} = renderWithProviders(, { + wrapperProps: { + bypassAuth: false + } + }) + + // Open the modal - passkey flow starts and stays pending + const trigger = screen.getByText(/open modal/i) + await user.click(trigger) + + await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + signal: expect.any(AbortSignal) + }) + ) + expect(capturedSignal).not.toBeNull() + }) + + // User logs in with password while passkey prompt is still open + await user.type(screen.getByLabelText(/email/i), 'customer@test.com') + await user.type(screen.getByLabelText(/^password$/i), 'Password!1') + await user.click(screen.getByRole('button', {name: /sign in/i})) + + await waitFor( + () => { + // Verify the passkey prompt was aborted + expect(capturedSignal.aborted).toBe(true) + // Verify the modal was closed + expect(screen.queryByRole('button', {name: /sign in/i})).not.toBeInTheDocument() + // Verify the user was signed in + expect(screen.getByText(/You're now signed in./i)).toBeInTheDocument() + }, + {timeout: 3000} + ) + }) + test('Does not trigger passkey when not enabled', async () => { const mockAppConfig = { ...mockConfig.app, @@ -845,6 +961,7 @@ describe('Passkey login', () => { const trigger = screen.getByText(/open modal/i) await user.click(trigger) + // Wait for modal to open await waitFor(() => { expect(screen.getByText(/welcome back/i)).toBeInTheDocument() }) @@ -917,7 +1034,8 @@ describe('Passkey login', () => { // login successfully and close the modal await waitFor(() => { - expect(screen.queryByText(/Welcome back/i)).not.toBeInTheDocument() + expect(screen.queryByRole('button', {name: /Sign in/i})).not.toBeInTheDocument() + expect(screen.getByText(/You're now signed in./i)).toBeInTheDocument() }) }) }) @@ -949,7 +1067,11 @@ describe('Passkey Registration', () => { }) test('shows passkey registration toast after login', async () => { - const {user} = renderWithProviders() + const {user} = renderWithProviders(, { + wrapperProps: { + bypassAuth: false + } + }) const validEmail = 'test@salesforce.com' const validPassword = 'Password123!' 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 index 497c874638..9cff1a6771 100644 --- a/packages/template-retail-react-app/app/hooks/use-passkey-login.js +++ b/packages/template-retail-react-app/app/hooks/use-passkey-login.js @@ -4,6 +4,7 @@ * 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 {useRef} from 'react' 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' @@ -15,6 +16,19 @@ export const usePasskeyLogin = () => { const startWebauthnAuthentication = useAuthHelper(AuthHelpers.StartWebauthnAuthentication) const finishWebauthnAuthentication = useAuthHelper(AuthHelpers.FinishWebauthnAuthentication) const {usid} = useUsid() + const abortControllerRef = useRef(null) + + /** + * Aborts any pending passkey login request. + * This is useful when the user logs in with a different method (e.g., password) + * while a passkey prompt (e.g., Touch ID, Face ID, etc.) is still open. + */ + const abortPasskeyLogin = () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort() + abortControllerRef.current = null + } + } const loginWithPasskey = async () => { const config = getConfig() @@ -57,22 +71,31 @@ export const usePasskeyLogin = () => { startWebauthnAuthenticationResponse.publicKey ) + // Create an AbortController to allow cancelling the passkey prompt + // This is needed when the user logs in with a different method or + // navigates away from the page while the passkey prompt is open + abortControllerRef.current = new AbortController() + // 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' + mediation: 'conditional', + signal: abortControllerRef.current.signal }) } 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') { + // AbortError is thrown when the passkey login is aborted programmatically (e.g., user logged in with password) + // We return early in these cases to avoid showing an error to the user + if (error.name === 'NotAllowedError' || error.name === 'AbortError') { return } console.error('Error getting passkey credential from browser:', error) throw error + } finally { + abortControllerRef.current = null } // Encode credential before sending to SLAS @@ -107,5 +130,5 @@ export const usePasskeyLogin = () => { return } - return {loginWithPasskey} + return {loginWithPasskey, abortPasskeyLogin} } 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 index fe4cb03b68..04c1fffe02 100644 --- 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 @@ -79,16 +79,21 @@ const mockIsConditionalMediationAvailable = jest.fn() const mockParseRequestOptionsFromJSON = jest.fn() const MockComponent = () => { - const {loginWithPasskey} = usePasskeyLogin() + const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin() const [result, setResult] = React.useState(null) - const handleClick = () => { + + const handleLogin = () => { loginWithPasskey() .then(() => setResult('resolved')) .catch(() => setResult('rejected')) } + const handleAbort = () => { + abortPasskeyLogin() + } return (
-
) @@ -143,217 +148,254 @@ describe('usePasskeyLogin', () => { 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' + describe('loginWithPasskey', () => { + 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', + signal: expect.any(AbortSignal) + }) }) }) - }) - 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 + 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() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - expect(mockGetCredentials).not.toHaveBeenCalled() - }) + expect(mockGetCredentials).not.toHaveBeenCalled() + }) - test('does not start passkey login when PublicKeyCredential is not available', async () => { - delete global.window.PublicKeyCredential + test('does not start passkey login when PublicKeyCredential is not available', async () => { + delete global.window.PublicKeyCredential - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - expect(mockGetCredentials).not.toHaveBeenCalled() - }) + expect(mockGetCredentials).not.toHaveBeenCalled() + }) - test('does not start passkey login when conditional mediation is not available', async () => { - mockIsConditionalMediationAvailable.mockResolvedValue(false) + test('does not start passkey login when conditional mediation is not available', async () => { + mockIsConditionalMediationAvailable.mockResolvedValue(false) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - await waitFor(() => { - expect(mockIsConditionalMediationAvailable).toHaveBeenCalled() + await waitFor(() => { + expect(mockIsConditionalMediationAvailable).toHaveBeenCalled() + }) + + expect(mockGetCredentials).not.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) - 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') + 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() }) - } + }) - // Reset and set the mock for this specific test to ensure it returns the credential - mockGetCredentials.mockResolvedValue(credentialWithoutToJSON) + test.each(['NotAllowedError', 'AbortError'])( + 'returns early without error when %s is thrown from navigator.credentials.get', + async (errorName) => { + const error = new Error() + error.name = errorName + mockGetCredentials.mockRejectedValue(error) - 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() - renderWithProviders() + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled()) + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') + ) + } + ) - await waitFor(() => { - expect(mockGetCredentials).toHaveBeenCalled() + test('returns early without error when 412 is returned from startWebauthnAuthentication', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res( + ctx.delay(0), + ctx.status(412), + ctx.json({message: 'Authenticate not started for: user@example.com"'}) + ) + }) + ) + + renderWithProviders() + + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) + + expect(mockGetCredentials).not.toHaveBeenCalled() + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') + ) }) - }) - test('returns early without error when NotAllowedError is thrown from navigator.credentials.get', async () => { - // Create a NotAllowedError (thrown when user cancels passkey login) - const notAllowedError = new Error('User cancelled') - notAllowedError.name = 'NotAllowedError' - mockGetCredentials.mockRejectedValue(notAllowedError) + test('throws error when other error is returned from startWebauthnAuthentication', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) + }) + ) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled()) - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') - ) - }) + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') + ) + }) - test('returns early without error when 412 is returned from startWebauthnAuthentication', async () => { - global.server.use( - rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { - return res( - ctx.delay(0), - ctx.status(412), - ctx.json({message: 'Authenticate not started for: user@example.com"'}) - ) - }) - ) + test('throws error when other error is returned from finishWebauthnAuthentication', async () => { + global.server.use( + rest.post('*/oauth2/webauthn/authenticate/finish', (req, res, ctx) => { + return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) + }) + ) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - expect(mockGetCredentials).not.toHaveBeenCalled() - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') - ) - }) + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') + ) + }) - test('throws error when other error is returned from startWebauthnAuthentication', async () => { - global.server.use( - rest.post('*/oauth2/webauthn/authenticate/start', (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) - }) - ) + test('throws error when other error is returned from navigator.credentials.get', async () => { + const networkError = new Error('NetworkError') + networkError.name = 'NetworkError' + mockGetCredentials.mockRejectedValue(networkError) - renderWithProviders() + renderWithProviders() - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + const trigger = screen.getByTestId('login-with-passkey') + fireEvent.click(trigger) - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') - ) + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') + ) + }) }) - test('throws error when other error is returned from finishWebauthnAuthentication', async () => { - global.server.use( - rest.post('*/oauth2/webauthn/authenticate/finish', (req, res, ctx) => { - return res(ctx.delay(0), ctx.status(500), ctx.json({message: '500 Error'})) + describe('abortPasskeyLogin', () => { + test('aborts the pending passkey request', async () => { + // Return a promise that rejects with AbortError when the abort signal fires + mockGetCredentials.mockImplementation(({signal}) => { + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + const abortError = new Error('Aborted') + abortError.name = 'AbortError' + reject(abortError) + }) + }) }) - ) - - renderWithProviders() - - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') - ) - }) + renderWithProviders() - test('throws error when other error is returned from navigator.credentials.get', async () => { - const networkError = new Error('NetworkError') - networkError.name = 'NetworkError' - mockGetCredentials.mockRejectedValue(networkError) + const loginTrigger = screen.getByTestId('login-with-passkey') + fireEvent.click(loginTrigger) - renderWithProviders() + // Wait for credentials.get to be called (passkey prompt is "waiting") + await waitFor(() => expect(mockGetCredentials).toHaveBeenCalled()) - const trigger = screen.getByTestId('login-with-passkey') - fireEvent.click(trigger) + // Abort while navigator.credentials.get is pending + const abortTrigger = screen.getByTestId('abort-passkey-login') + fireEvent.click(abortTrigger) - await waitFor(() => - expect(screen.getByTestId('login-result')).toHaveTextContent('rejected') - ) + // Hook catches AbortError and returns, so loginWithPasskey resolves + await waitFor(() => + expect(screen.getByTestId('login-result')).toHaveTextContent('resolved') + ) + }) }) }) 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 81fdb4c5e0..edac001d69 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 @@ -62,7 +62,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 {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin() const {step, STEPS, goToStep, goToNextStep} = useCheckout() @@ -169,10 +169,15 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id } } - if (!customer.isRegistered) { + if (step === STEPS.CONTACT_INFO) { handlePasskeyLogin() } - }, [customer.isRegistered]) + + // Cleanup: abort passkey login when navigating away from checkout + return () => { + abortPasskeyLogin() + } + }, [step]) const onPasswordlessLoginClick = async (e) => { const isValid = await form.trigger('email') 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 215c77aca8..893cc90f45 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 @@ -16,7 +16,8 @@ import { import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket' import { mockGoToStep, - mockGoToNextStep + mockGoToNextStep, + useCheckout } from '@salesforce/retail-react-app/app/pages/checkout/util/checkout-context' import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' @@ -72,12 +73,14 @@ jest.mock('@salesforce/retail-react-app/app/hooks/use-current-basket', () => { }) const mockLoginWithPasskey = jest.fn().mockResolvedValue(undefined) +const mockAbortPasskeyLogin = jest.fn() jest.mock('@salesforce/retail-react-app/app/hooks/use-passkey-login', () => { return { __esModule: true, usePasskeyLogin: jest.fn(() => ({ - loginWithPasskey: mockLoginWithPasskey + loginWithPasskey: mockLoginWithPasskey, + abortPasskeyLogin: mockAbortPasskeyLogin })) } }) @@ -428,8 +431,34 @@ describe('navigation based on shipment context', () => { }) describe('passkey login', () => { + let currentBasket = JSON.parse(JSON.stringify(scapiBasketWithItem)) + const MOCK_STEPS = {CONTACT_INFO: 0, PAYMENT: 2} + beforeEach(() => { jest.clearAllMocks() + // Reset useCheckout mock to default values + useCheckout.mockReturnValue({ + customer: null, + basket: {}, + isGuestCheckout: true, + setIsGuestCheckout: jest.fn(), + step: MOCK_STEPS.CONTACT_INFO, + login: null, + STEPS: MOCK_STEPS, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep + }) + global.server.use( + rest.put('*/baskets/:basketId/customer', (req, res, ctx) => { + currentBasket.customerInfo.email = validEmail + return res(ctx.json(currentBasket)) + }) + ) + // Provide basket with basketId and items for tests in this suite + useCurrentBasket.mockReturnValue({ + data: currentBasket, + derivedData: {totalItems: currentBasket.productItems?.length || 0} + }) // Default to guest user (not registered) mockUseCurrentCustomer.mockReturnValue({ data: { @@ -455,20 +484,87 @@ describe('passkey login', () => { }) }) - test('does not call loginWithPasskey when customer is registered', async () => { - // Mock registered customer - mockUseCurrentCustomer.mockReturnValue({ - data: { - isRegistered: true, - email: 'test@example.com' - } + test('does not prompt for passkey when not on contact info step', async () => { + // When step is not CONTACT_INFO, we must not trigger passkey (no prompt) + useCheckout.mockReturnValue({ + customer: null, + basket: {}, + isGuestCheckout: true, + setIsGuestCheckout: jest.fn(), + step: MOCK_STEPS.PAYMENT, + login: null, + STEPS: MOCK_STEPS, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep }) renderWithProviders() - // Wait a bit to ensure useEffect has run await waitFor(() => { expect(mockLoginWithPasskey).not.toHaveBeenCalled() }) }) + + test('calls abortPasskeyLogin when component unmounts', async () => { + const {unmount} = renderWithProviders() + + // Wait for passkey login to be triggered + await waitFor(() => { + expect(mockLoginWithPasskey).toHaveBeenCalled() + }) + + // Verify abort hasn't been called yet + expect(mockAbortPasskeyLogin).not.toHaveBeenCalled() + + // Unmount the component (simulates navigating away) + unmount() + + // Verify abort was called during cleanup + expect(mockAbortPasskeyLogin).toHaveBeenCalled() + }) + + test('Passkey prompt is aborted when user logs in with password', async () => { + // This test verifies that when the user logs in with password while the passkey + // flow is pending, the useEffect cleanup runs (step changes) and + // abortPasskeyLogin is called. + useCheckout.mockImplementation(() => ({ + customer: null, + basket: {}, + isGuestCheckout: true, + setIsGuestCheckout: jest.fn(), + step: + // Make step "change" after goToNextStep is called so the effect cleanup runs. + mockGoToNextStep.mock.calls.length > 0 + ? MOCK_STEPS.PAYMENT + : MOCK_STEPS.CONTACT_INFO, + login: null, + STEPS: MOCK_STEPS, + goToStep: mockGoToStep, + goToNextStep: mockGoToNextStep + })) + + const {user} = renderWithProviders() + + // Wait for passkey login to be triggered (passkey prompt is "pending") + await waitFor(() => { + expect(mockLoginWithPasskey).toHaveBeenCalled() + }) + expect(mockAbortPasskeyLogin).not.toHaveBeenCalled() + + // User switches to login and logs in with password + const trigger = screen.getByText(/Already have an account\? Log in/i) + await user.click(trigger) + + await user.type(screen.getByLabelText('Email'), validEmail) + await user.type(screen.getByLabelText('Password'), password) + + const loginButton = screen.getByText('Log In') + await user.click(loginButton) + + // Login succeeds; goToNextStep is called. Next render will see step change + await waitFor(() => { + expect(mockGoToNextStep).toHaveBeenCalled() + expect(mockAbortPasskeyLogin).toHaveBeenCalled() + }) + }) }) diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx index a83fcc4362..9d51dcf41c 100644 --- a/packages/template-retail-react-app/app/pages/login/index.jsx +++ b/packages/template-retail-react-app/app/pages/login/index.jsx @@ -56,7 +56,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const {path} = useRouteMatch() const einstein = useEinstein() const dataCloud = useDataCloud() - const {isRegistered, customerType} = useCustomerType() + const {isRegistered, customerType, isGuest} = useCustomerType() const {locale} = useMultiSite() const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const loginPasswordless = useAuthHelper(AuthHelpers.LoginPasswordlessUser) @@ -81,7 +81,7 @@ const Login = ({initialView = LOGIN_VIEW}) => { const mergeBasket = useShopperBasketsMutation('mergeBasket') const [redirectPath, setRedirectPath] = useState('') const {showRegisterPasskeyToast} = usePasskeyRegistration() - const {loginWithPasskey} = usePasskeyLogin() + const {loginWithPasskey, abortPasskeyLogin} = usePasskeyLogin() const [isOtpAuthOpen, setIsOtpAuthOpen] = useState(false) const handleMergeBasket = () => { @@ -191,10 +191,17 @@ const Login = ({initialView = LOGIN_VIEW}) => { }, [isRegistered, redirectPath]) useEffect(() => { - loginWithPasskey().catch(() => { - form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) - }) - }, []) + if (isGuest) { + loginWithPasskey().catch(() => { + form.setError('global', {type: 'manual', message: formatMessage(API_ERROR_MESSAGE)}) + }) + } + + // Cleanup: abort passkey login when navigating away from login page + return () => { + abortPasskeyLogin() + } + }, [isGuest]) /**************** Einstein ****************/ useEffect(() => { diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js index 4c170c08e6..cb79a8b82e 100644 --- a/packages/template-retail-react-app/app/pages/login/index.test.js +++ b/packages/template-retail-react-app/app/pages/login/index.test.js @@ -10,7 +10,9 @@ import {rest} from 'msw' import { renderWithProviders, createPathWithDefaults, - guestToken + guestToken, + registerUserToken, + clearAllCookies } from '@salesforce/retail-react-app/app/utils/test-utils' import Login from '.' import {BrowserRouter as Router, Route} from 'react-router-dom' @@ -20,12 +22,23 @@ import ResetPassword from '@salesforce/retail-react-app/app/pages/reset-password import mockConfig from '@salesforce/retail-react-app/config/mocks/default' import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data' import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config' +import {useCustomerType} from '@salesforce/commerce-sdk-react' // Mock getConfig for passkey tests jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => ({ getConfig: jest.fn() })) +// Allows overriding useCustomerType for tests that need a specific auth +// state (e.g. simulate a user being already authenticated on page load. +jest.mock('@salesforce/commerce-sdk-react', () => { + const actual = jest.requireActual('@salesforce/commerce-sdk-react') + return { + ...actual, + useCustomerType: jest.fn(actual.useCustomerType) + } +}) + const mockMergedBasket = { basketId: 'a10ff320829cb0eef93ca5310a', currency: 'USD', @@ -88,6 +101,8 @@ beforeEach(() => { afterEach(() => { jest.resetModules() localStorage.clear() + // Ensures authenticated state from previous tests don't leak into subsequent tests + clearAllCookies() }) describe('Logging in tests', function () { @@ -280,6 +295,7 @@ describe('Error while logging in', function () { ).toBeInTheDocument() }) }) + describe('Passkey login', () => { let mockCredentialsGet let mockPublicKeyCredential @@ -436,6 +452,40 @@ describe('Passkey login', () => { expect(mockCredentialsGet).not.toHaveBeenCalled() }) + test('Does not trigger passkey when user is already signed in', async () => { + // Simulates a user being already authenticated on page load + const realUseCustomerType = useCustomerType.getMockImplementation() + useCustomerType.mockReturnValue({ + isRegistered: true, + customerType: 'registered', + isGuest: false, + isExternal: false + }) + try { + renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: true, + isGuest: false + } + }) + + await waitFor(() => { + expect(screen.getByTestId('login-page')).toBeInTheDocument() + }) + + // Give it a moment for any async effects to run + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Rendering the login page should not trigger navigator.credentials.get when user is already registered + expect(mockCredentialsGet).not.toHaveBeenCalled() + } finally { + useCustomerType.mockImplementation(realUseCustomerType) + } + }) + test('Successfully logs in with passkey', async () => { const mockCredential = { id: 'mock-credential-id', @@ -531,60 +581,6 @@ describe('Passkey login', () => { }) }) - describe('Passkey Registration', () => { - test('Displays Create passkey toast after successful login when passkey is enabled', async () => { - // Successful email/password login - global.server.use( - rest.post('*/oauth2/token', (req, res, ctx) => - res( - ctx.delay(0), - ctx.json({ - customer_id: 'customerid_1', - access_token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', - refresh_token: 'testrefeshtoken_1', - usid: 'testusid_1', - enc_user_id: 'testEncUserId_1', - id_token: 'testIdToken_1' - }) - ) - ), - rest.post('*/baskets/actions/merge', (req, res, ctx) => - res(ctx.delay(0), ctx.json(mockMergedBasket)) - ) - ) - - const {user} = renderWithProviders(, { - wrapperProps: { - siteAlias: 'uk', - locale: {id: 'en-GB'}, - appConfig: mockAppConfig, - bypassAuth: false - } - }) - - // Wait for login form after passkey is cancelled - await waitFor(() => { - expect(screen.getByLabelText('Email')).toBeInTheDocument() - expect(screen.getByLabelText('Password')).toBeInTheDocument() - }) - - await user.type(screen.getByLabelText('Email'), 'customer@test.com') - await user.type(screen.getByLabelText('Password'), 'Password!1') - await user.click(screen.getByRole('button', {name: /sign in/i})) - - // Create passkey toast is shown after successful login when passkey is enabled and WebAuthn is supported - await waitFor( - () => { - expect( - screen.getByRole('button', {name: /Create Passkey/i}) - ).toBeInTheDocument() - }, - {timeout: 3000} - ) - }) - }) - test('Shows error when passkey authentication fails with error from the browser', async () => { // Simulate error in navigator.credentials.get hook mockCredentialsGet.mockRejectedValue(new Error('Authentication failed')) @@ -631,6 +627,189 @@ describe('Passkey login', () => { expect(screen.getByText(/Something went wrong. Try again!/i)).toBeInTheDocument() }) }) + + test('Passkey prompt is aborted when user logs in with password', async () => { + // Capture the abort signal passed to credentials.get + let capturedSignal = null + + // Mock credentials.get to capture the abort signal and stay pending + mockCredentialsGet.mockImplementation(({signal}) => { + capturedSignal = signal + return new Promise(() => { + // Never resolve - simulates passkey prompt staying open + }) + }) + + // Successful email/password login + global.server.use( + rest.post('*/oauth2/token', (req, res, ctx) => + res( + ctx.delay(0), + ctx.json({ + customer_id: 'customerid_1', + access_token: registerUserToken, + refresh_token: 'testrefeshtoken_1', + usid: 'testusid_1', + enc_user_id: 'testEncUserId_1', + id_token: 'testIdToken_1' + }) + ) + ), + rest.post('*/baskets/actions/merge', (req, res, ctx) => + res(ctx.delay(0), ctx.json(mockMergedBasket)) + ) + ) + + const {user} = renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: false + } + }) + + // Wait for passkey conditional mediation to start and capture the signal + await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + signal: expect.any(AbortSignal) + }) + ) + expect(capturedSignal).not.toBeNull() + }) + + // Verify signal is not yet aborted + expect(capturedSignal.aborted).toBe(false) + + // User logs in with password while passkey prompt is still open + await user.type(screen.getByLabelText('Email'), 'customer@test.com') + await user.type(screen.getByLabelText('Password'), 'Password!1') + await user.click(screen.getByRole('button', {name: /sign in/i})) + + // Wait for successful login and navigation to account page + await waitFor( + () => { + expect(window.location.pathname).toBe('/uk/en-GB/account') + expect(screen.getByText(/My Profile/i)).toBeInTheDocument() + }, + {timeout: 3000} + ) + + // Verify the signal was aborted when user logs in with password + expect(capturedSignal.aborted).toBe(true) + }) + + test('Passkey prompt is aborted when navigating away from login page', async () => { + // Capture the abort signal passed to credentials.get + let capturedSignal = null + + // Mock credentials.get to capture the abort signal and stay pending + mockCredentialsGet.mockImplementation(({signal}) => { + capturedSignal = signal + return new Promise(() => { + // Never resolve - simulates passkey prompt staying open + }) + }) + + const {unmount} = renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + appConfig: mockAppConfig, + bypassAuth: false + } + }) + + // Wait for passkey conditional mediation to start and capture the signal + await waitFor(() => { + expect(mockCredentialsGet).toHaveBeenCalledWith( + expect.objectContaining({ + mediation: 'conditional', + signal: expect.any(AbortSignal) + }) + ) + expect(capturedSignal).not.toBeNull() + }) + + // Verify signal is not yet aborted + expect(capturedSignal.aborted).toBe(false) + + // Simulate navigating away from the login page by unmounting + unmount() + + // Verify the signal was aborted when component unmounted + expect(capturedSignal.aborted).toBe(true) + }) +}) + +describe('Passkey Registration', () => { + let mockPublicKeyCredential + + beforeEach(() => { + mockPublicKeyCredential = { + parseRequestOptionsFromJSON: jest.fn(), + isConditionalMediationAvailable: jest.fn().mockResolvedValue(true), + isUserVerifyingPlatformAuthenticatorAvailable: jest.fn().mockResolvedValue(true) + } + + global.PublicKeyCredential = mockPublicKeyCredential + global.window.PublicKeyCredential = mockPublicKeyCredential + }) + + afterEach(() => { + delete global.PublicKeyCredential + delete global.window.PublicKeyCredential + }) + + test('Displays Create passkey toast after successful login when passkey is enabled', async () => { + // Successful email/password login + global.server.use( + rest.post('*/oauth2/token', (req, res, ctx) => + res( + ctx.delay(0), + ctx.json({ + customer_id: 'customerid_1', + access_token: registerUserToken, + refresh_token: 'testrefeshtoken_1', + usid: 'testusid_1', + enc_user_id: 'testEncUserId_1', + id_token: 'testIdToken_1' + }) + ) + ), + rest.post('*/baskets/actions/merge', (req, res, ctx) => + res(ctx.delay(0), ctx.json(mockMergedBasket)) + ) + ) + + const {user} = renderWithProviders(, { + wrapperProps: { + siteAlias: 'uk', + locale: {id: 'en-GB'}, + bypassAuth: false + } + }) + + // Wait for login form after passkey is cancelled + await waitFor(() => { + expect(screen.getByLabelText('Email')).toBeInTheDocument() + expect(screen.getByLabelText('Password')).toBeInTheDocument() + }) + + await user.type(screen.getByLabelText('Email'), 'customer@test.com') + await user.type(screen.getByLabelText('Password'), 'Password!1') + await user.click(screen.getByRole('button', {name: /sign in/i})) + + // Create passkey toast is shown after successful login when passkey is enabled and WebAuthn is supported + await waitFor( + () => { + expect(screen.getByRole('button', {name: /Create Passkey/i})).toBeInTheDocument() + }, + {timeout: 3000} + ) + }) }) describe('Navigate away from login page tests', function () { @@ -693,8 +872,7 @@ describe('Passwordless login tests', () => { ctx.status(200), ctx.json({ customer_id: 'customerid_1', - access_token: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXQiOiJHVUlEIiwic2NwIjoic2ZjYy5zaG9wcGVyLW15YWNjb3VudC5iYXNrZXRzIHNmY2Muc2hvcHBlci1teWFjY291bnQuYWRkcmVzc2VzIHNmY2Muc2hvcHBlci1wcm9kdWN0cyBzZmNjLnNob3BwZXItZGlzY292ZXJ5LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnJ3IHNmY2Muc2hvcHBlci1teWFjY291bnQucGF5bWVudGluc3RydW1lbnRzIHNmY2Muc2hvcHBlci1jdXN0b21lcnMubG9naW4gc2ZjYy5zaG9wcGVyLWV4cGVyaWVuY2Ugc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5vcmRlcnMgc2ZjYy5zaG9wcGVyLWN1c3RvbWVycy5yZWdpc3RlciBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5hZGRyZXNzZXMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wcm9kdWN0bGlzdHMucncgc2ZjYy5zaG9wcGVyLXByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItcHJvbW90aW9ucyBzZmNjLnNob3BwZXItYmFza2V0cy1vcmRlcnMucncgc2ZjYy5zaG9wcGVyLW15YWNjb3VudC5wYXltZW50aW5zdHJ1bWVudHMucncgc2ZjYy5zaG9wcGVyLWdpZnQtY2VydGlmaWNhdGVzIHNmY2Muc2hvcHBlci1wcm9kdWN0LXNlYXJjaCBzZmNjLnNob3BwZXItbXlhY2NvdW50LnByb2R1Y3RsaXN0cyBzZmNjLnNob3BwZXItY2F0ZWdvcmllcyBzZmNjLnNob3BwZXItbXlhY2NvdW50Iiwic3ViIjoiY2Mtc2xhczo6enpyZl8wMDE6OnNjaWQ6YzljNDViZmQtMGVkMy00YWEyLTk5NzEtNDBmODg5NjJiODM2Ojp1c2lkOjhlODgzOTczLTY4ZWItNDFmZS1hM2M1LTc1NjIzMjY1MmZmNSIsImN0eCI6InNsYXMiLCJpc3MiOiJzbGFzL3Byb2QvenpyZl8wMDEiLCJpc3QiOjEsImF1ZCI6ImNvbW1lcmNlY2xvdWQvcHJvZC96enJmXzAwMSIsIm5iZiI6MTY3ODgzNDI3MSwic3R5IjoiVXNlciIsImlzYiI6InVpZG86ZWNvbTo6dXBuOmtldjVAdGVzdC5jb206OnVpZG46a2V2aW4gaGU6OmdjaWQ6YWJtZXMybWJrM2xYa1JsSEZKd0dZWWt1eEo6OnJjaWQ6YWJVTXNhdnBEOVk2alcwMGRpMlNqeEdDTVU6OmNoaWQ6UmVmQXJjaEdsb2JhbCIsImV4cCI6MjY3ODgzNjEwMSwiaWF0IjoxNjc4ODM0MzAxLCJqdGkiOiJDMkM0ODU2MjAxODYwLTE4OTA2Nzg5MDM0ODA1ODMyNTcwNjY2NTQyIn0._tUrxeXdFYPj6ZoY-GILFRd3-aD1RGPkZX6TqHeS494', + access_token: registerUserToken, refresh_token: 'testrefeshtoken_1', usid: 'testusid_1', enc_user_id: 'testEncUserId_1',