From 9c18b32fad71c4190c2a53cb2d4ea77d4efa646f Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 22 Sep 2026 13:14:22 -0700 Subject: [PATCH] fix(expo): keep hasCredentials false when the biometric prompt is cancelled --- ...expo-local-credentials-cancelled-prompt.md | 5 + .../__tests__/useLocalCredentials.test.ts | 117 ++++++++++++++++++ .../useLocalCredentials.ts | 15 +-- 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 .changeset/expo-local-credentials-cancelled-prompt.md create mode 100644 packages/expo/src/local-credentials/useLocalCredentials/__tests__/useLocalCredentials.test.ts diff --git a/.changeset/expo-local-credentials-cancelled-prompt.md b/.changeset/expo-local-credentials-cancelled-prompt.md new file mode 100644 index 00000000000..3c69352e50e --- /dev/null +++ b/.changeset/expo-local-credentials-cancelled-prompt.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix `useLocalCredentials()` reporting `hasCredentials` as `true` after the biometric prompt is cancelled during `setCredentials()`. A cancelled prompt now leaves the stored credentials unchanged, so `authenticate()` no longer fails with a missing password. diff --git a/packages/expo/src/local-credentials/useLocalCredentials/__tests__/useLocalCredentials.test.ts b/packages/expo/src/local-credentials/useLocalCredentials/__tests__/useLocalCredentials.test.ts new file mode 100644 index 00000000000..f560de6d005 --- /dev/null +++ b/packages/expo/src/local-credentials/useLocalCredentials/__tests__/useLocalCredentials.test.ts @@ -0,0 +1,117 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useLocalCredentials } from '../useLocalCredentials'; + +const mocks = vi.hoisted(() => ({ + publishableKey: 'pk_test_Zm9vLmNsZXJrLmFjY291bnRzLmRldiQ', + store: new Map(), + rejectProtectedWrites: false, + signIn: { create: vi.fn() }, +})); + +vi.mock('@clerk/react', () => ({ + useClerk: () => ({ publishableKey: mocks.publishableKey }), + useUser: () => ({ user: null }), +})); + +vi.mock('@clerk/react/legacy', () => ({ + useSignIn: () => ({ isLoaded: true, signIn: mocks.signIn }), +})); + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, +})); + +vi.mock('../../../utils/native-module', () => ({ + ClerkExpoModule: {}, +})); + +vi.mock('expo-local-authentication', () => ({ + AuthenticationType: { FINGERPRINT: 1, FACIAL_RECOGNITION: 2, IRIS: 3 }, + isEnrolledAsync: () => Promise.resolve(true), + supportedAuthenticationTypesAsync: () => Promise.resolve([]), +})); + +vi.mock('expo-secure-store', () => ({ + WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 0, + getItem: (key: string) => mocks.store.get(key) ?? null, + getItemAsync: (key: string) => Promise.resolve(mocks.store.get(key) ?? null), + deleteItemAsync: (key: string) => { + mocks.store.delete(key); + return Promise.resolve(); + }, + setItemAsync: (key: string, value: string, options?: { requireAuthentication?: boolean }) => { + if (options?.requireAuthentication && mocks.rejectProtectedWrites) { + return Promise.reject(new Error('User canceled the authentication')); + } + mocks.store.set(key, value); + return Promise.resolve(); + }, +})); + +const identifierKey = `__clerk_local_auth_${mocks.publishableKey}_identifier`; +const passwordKey = `__clerk_local_auth_${mocks.publishableKey}_password`; + +beforeEach(() => { + mocks.store.clear(); + mocks.rejectProtectedWrites = false; +}); + +describe('useLocalCredentials', () => { + test('reports credentials once both writes succeed', async () => { + const { result } = renderHook(() => useLocalCredentials()); + + await act(() => result.current.setCredentials({ identifier: 'user@example.com', password: 'hunter2' })); + + expect(result.current.hasCredentials).toBe(true); + expect(mocks.store.get(identifierKey)).toBe('user@example.com'); + expect(mocks.store.get(passwordKey)).toBe('hunter2'); + }); + + test('does not report credentials when the biometric prompt is cancelled', async () => { + mocks.rejectProtectedWrites = true; + const { result } = renderHook(() => useLocalCredentials()); + + await act(async () => { + await expect( + result.current.setCredentials({ identifier: 'user@example.com', password: 'hunter2' }), + ).rejects.toThrow('User canceled the authentication'); + }); + + expect(result.current.hasCredentials).toBe(false); + expect(mocks.store.has(identifierKey)).toBe(false); + expect(mocks.store.has(passwordKey)).toBe(false); + + const remounted = renderHook(() => useLocalCredentials()); + expect(remounted.result.current.hasCredentials).toBe(false); + }); + + test('keeps the existing credentials when a password update is cancelled', async () => { + mocks.store.set(identifierKey, 'user@example.com'); + mocks.store.set(passwordKey, 'hunter2'); + mocks.rejectProtectedWrites = true; + const { result } = renderHook(() => useLocalCredentials()); + + await act(async () => { + await expect(result.current.setCredentials({ password: 'new-password' })).rejects.toThrow(); + }); + + expect(result.current.hasCredentials).toBe(true); + expect(mocks.store.get(identifierKey)).toBe('user@example.com'); + expect(mocks.store.get(passwordKey)).toBe('hunter2'); + }); + + test('rejects a password update when no identifier is stored', async () => { + const { result } = renderHook(() => useLocalCredentials()); + + await act(async () => { + await expect(result.current.setCredentials({ password: 'hunter2' })).rejects.toThrow( + 'an identifier should already be set', + ); + }); + + expect(result.current.hasCredentials).toBe(false); + expect(mocks.store.has(passwordKey)).toBe(false); + }); +}); diff --git a/packages/expo/src/local-credentials/useLocalCredentials/useLocalCredentials.ts b/packages/expo/src/local-credentials/useLocalCredentials/useLocalCredentials.ts index 1705353f808..bd38a552502 100644 --- a/packages/expo/src/local-credentials/useLocalCredentials/useLocalCredentials.ts +++ b/packages/expo/src/local-credentials/useLocalCredentials/useLocalCredentials.ts @@ -134,23 +134,24 @@ export const useLocalCredentials = (): LocalCredentialsReturn => { ); } - if (creds.identifier) { - await setItemAsync(key, creds.identifier); - } + const identifier = creds.identifier ?? (await getItemAsync(key).catch(() => null)); - const storedIdentifier = await getItemAsync(key).catch(() => null); - - if (!storedIdentifier) { + if (!identifier) { return errorThrower.throw( `useLocalCredentials: setCredentials() an identifier should already be set in order to update its password.`, ); } - setHasLocalAuthCredentials(true); await setItemAsync(pkey, creds.password, { keychainAccessible: WHEN_PASSCODE_SET_THIS_DEVICE_ONLY, requireAuthentication: true, }); + + if (creds.identifier) { + await setItemAsync(key, creds.identifier); + } + + setHasLocalAuthCredentials(true); }; const clearCredentials = async () => {