From 3a33b510f4244f9b6d8a65fbab0f663f353dd384 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:31:28 -0400 Subject: [PATCH 1/3] feat(expo): move biometric credentials to @clerk/expo-biometrics Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/expo-biometric-credentials-js.md | 11 + packages/expo/package.json | 5 + .../__tests__/reverification.test.ts | 32 +- .../__tests__/useBiometricCredentials.test.ts | 1134 ++++++++++------- .../createBiometricCredentials.ts | 493 +++++++ .../loadExpoBiometrics.ts | 20 + .../nativeReverification.ts | 65 + .../useBiometricCredentials.shared.ts | 190 +-- .../expo/src/specs/NativeClerkModule.types.ts | 41 +- pnpm-lock.yaml | 3 + 10 files changed, 1309 insertions(+), 685 deletions(-) create mode 100644 .changeset/expo-biometric-credentials-js.md create mode 100644 packages/expo/src/biometric-credentials/createBiometricCredentials.ts create mode 100644 packages/expo/src/biometric-credentials/loadExpoBiometrics.ts create mode 100644 packages/expo/src/biometric-credentials/nativeReverification.ts diff --git a/.changeset/expo-biometric-credentials-js.md b/.changeset/expo-biometric-credentials-js.md new file mode 100644 index 00000000000..e0ec78666d4 --- /dev/null +++ b/.changeset/expo-biometric-credentials-js.md @@ -0,0 +1,11 @@ +--- +'@clerk/expo': minor +--- + +`useBiometricCredentials()` now enrolls, lists, revokes and signs in with biometric credentials through `@clerk/expo-biometrics` and Clerk's JavaScript SDK, instead of Clerk's native iOS and Android SDKs. + +- Install `@clerk/expo-biometrics` (`npx expo install @clerk/expo-biometrics`) and rebuild your development build. Without it, these methods throw an error explaining how to install it. +- Enrollment, listing, revocation and sign-in no longer require `@clerk/expo-native-components` or iOS 17. +- `reverify()` still uses Clerk's native SDK and requires `@clerk/expo-native-components`. +- Errors returned by Clerk's API are now thrown as `ClerkAPIResponseError`, like the rest of `@clerk/expo`. Read the API error code from `error.errors[0].code`. Errors raised on the device, such as `biometric_authentication_canceled` or `key_invalidated`, keep their `code`. +- On Android, credentials enrolled through the previous native implementation may not be found by apps that don't use `@clerk/expo-native-components`. Users of those apps need to enroll again. diff --git a/packages/expo/package.json b/packages/expo/package.json index 095cc4ddba5..5e26f032553 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -121,6 +121,7 @@ "tslib": "catalog:repo" }, "devDependencies": { + "@clerk/expo-biometrics": "workspace:*", "@clerk/expo-google-signin": "workspace:*", "@clerk/expo-native-components": "workspace:*", "@clerk/expo-passkeys": "workspace:*", @@ -137,6 +138,7 @@ "react-native": "^0.86.0" }, "peerDependencies": { + "@clerk/expo-biometrics": ">=0.1.0", "@clerk/expo-google-signin": ">=0.1.0", "@clerk/expo-native-components": ">=0.1.0", "@clerk/expo-passkeys": ">=0.0.6", @@ -153,6 +155,9 @@ "react-native": ">=0.75" }, "peerDependenciesMeta": { + "@clerk/expo-biometrics": { + "optional": true + }, "@clerk/expo-google-signin": { "optional": true }, diff --git a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts index 759b2583cb7..0db30f9905d 100644 --- a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts @@ -9,18 +9,20 @@ const mocks = vi.hoisted(() => ({ useClerk: vi.fn(), idle: vi.fn(), synchronize: vi.fn(), + isNativeModuleInstalled: true, nativeModule: { - getTrustedDeviceAvailability: vi.fn(), - listTrustedDevices: vi.fn(), - enrollTrustedDevice: vi.fn(), - revokeTrustedDevice: vi.fn(), - signInWithTrustedDevice: vi.fn(), reverifyWithBiometrics: vi.fn(), }, + loadExpoBiometrics: vi.fn(), })); vi.mock('@clerk/react', () => ({ useClerk: mocks.useClerk })); -vi.mock('../../utils/native-module', () => ({ ClerkExpoModule: mocks.nativeModule })); +vi.mock('../../utils/native-module', () => ({ + get ClerkExpoModule() { + return mocks.isNativeModuleInstalled ? mocks.nativeModule : null; + }, +})); +vi.mock('../loadExpoBiometrics', () => ({ loadExpoBiometrics: mocks.loadExpoBiometrics })); vi.mock('../../provider/nativeClientSync', () => ({ idle: mocks.idle, pullFromNative: mocks.synchronize })); vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })); @@ -70,7 +72,7 @@ describe.each([ expect(session.getToken).toHaveBeenCalledWith({ skipCache: true }); expect(synchronize.mock.invocationCallOrder[0]).toBeLessThan(session.getToken.mock.invocationCallOrder[0]); expect(clerk.setActive).not.toHaveBeenCalled(); - expect(mocks.nativeModule.signInWithTrustedDevice).not.toHaveBeenCalled(); + expect(mocks.loadExpoBiometrics).not.toHaveBeenCalled(); }); test.each(['first_factor', 'second_factor', 'multi_factor'] as const)( @@ -217,19 +219,29 @@ test('rejects completion if the active session changes while refreshing its toke expect(clerk.setActive).not.toHaveBeenCalled(); }); -test('older native builds keep existing operations but explain the missing reverification method', async () => { +test('explains the missing reverification method in older @clerk/expo-native-components builds', async () => { const reverify = mocks.nativeModule.reverifyWithBiometrics; Object.assign(mocks.nativeModule, { reverifyWithBiometrics: undefined }); - mocks.nativeModule.listTrustedDevices.mockResolvedValue([]); try { const { result } = renderHook(useIosBiometrics); - await expect(result.current.list()).resolves.toEqual([]); await expect(result.current.reverify()).rejects.toThrow('Biometric reverification requires a development build'); } finally { Object.assign(mocks.nativeModule, { reverifyWithBiometrics: reverify }); } }); +test('explains how to install @clerk/expo-native-components', async () => { + mocks.isNativeModuleInstalled = false; + try { + const { result } = renderHook(useIosBiometrics); + await expect(result.current.reverify()).rejects.toThrow( + 'Biometric reverification requires the @clerk/expo-native-components package in a development build. Install it with `npx expo install @clerk/expo-native-components`', + ); + } finally { + mocks.isNativeModuleInstalled = true; + } +}); + test('unsupported platforms reject reverification', async () => { const { result } = renderHook(useUnsupportedBiometrics); await expect(result.current.reverify()).rejects.toThrow('only available on iOS and Android'); diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts index d5f8ddbaa89..d2805f54e1c 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -1,624 +1,860 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; import { renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { isBiometricCredentialError } from '../errors'; +import type { ExpoBiometricsRecord } from '../loadExpoBiometrics'; import { useBiometricCredentials as useBiometricCredentialsOnUnsupportedPlatform } from '../useBiometricCredentials'; import { useBiometricCredentials as useBiometricCredentialsOnAndroid } from '../useBiometricCredentials.android'; import { useBiometricCredentials as useBiometricCredentialsOnIos } from '../useBiometricCredentials.ios'; const mocks = vi.hoisted(() => ({ - jsSignIn: { - id: 'sia_123', - status: 'complete', - createdSessionId: 'sess_123', - prepareSecondFactor: vi.fn(), - attemptSecondFactor: vi.fn(), - resetPassword: vi.fn(), - }, - jsSignedInSessions: [{ id: 'sess_123' }], useClerk: vi.fn(), - setActive: vi.fn(), + loadExpoBiometrics: vi.fn(), idle: vi.fn(), pullFromNative: vi.fn(), - isNativeModuleInstalled: true, - nativeModule: { - getTrustedDeviceAvailability: vi.fn(), - listTrustedDevices: vi.fn(), - enrollTrustedDevice: vi.fn(), - revokeTrustedDevice: vi.fn(), - signInWithTrustedDevice: vi.fn(), - }, + platform: { OS: 'ios' }, })); -vi.mock('@clerk/react', () => ({ - useClerk: mocks.useClerk, -})); +vi.mock('@clerk/react', () => ({ useClerk: mocks.useClerk })); +vi.mock('../loadExpoBiometrics', () => ({ loadExpoBiometrics: mocks.loadExpoBiometrics })); +vi.mock('../../provider/nativeClientSync', () => ({ idle: mocks.idle, pullFromNative: mocks.pullFromNative })); +vi.mock('../../utils/native-module', () => ({ ClerkExpoModule: null })); +vi.mock('react-native', () => ({ Platform: mocks.platform })); + +const APP_IDENTIFIER = 'com.example.app'; + +const moduleError = (code: string, message = code) => Object.assign(new Error(message), { code }); + +const asyncFn = (implementation: (...args: Args) => Result) => + vi.fn((...args: Args) => Promise.resolve(implementation(...args))); + +const hashHint = (hint: string) => `sha256(${hint})`; + +function localRecord(overrides: Partial = {}): ExpoBiometricsRecord { + return { + id: 'td_1', + localKeyId: 'key_1', + userId: 'user_1', + appIdentifier: APP_IDENTIFIER, + policy: 'biometry_current_set', + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + identifierHintSha256: null, + ...overrides, + }; +} -vi.mock('../../provider/nativeClientSync', () => ({ - idle: mocks.idle, - pullFromNative: mocks.pullFromNative, -})); +function createExpoBiometrics({ withHash = true }: { withHash?: boolean } = {}) { + const store = { + records: [] as ExpoBiometricsRecord[], + keys: new Set(), + }; + const module = { + store, + getAppIdentifier: vi.fn(() => APP_IDENTIFIER), + getAvailability: asyncFn(() => ({ + biometryType: 'faceID', + canEvaluateBiometrics: true, + canEvaluateDeviceOwner: true, + errorCode: null, + })), + createKey: asyncFn(() => { + store.keys.add('key_new'); + return { localKeyId: 'key_new', publicKeyJwk: '{"kty":"EC","crv":"P-256","x":"x","y":"y","alg":"ES256"}' }; + }), + sign: asyncFn(() => 'signature'), + hasKey: asyncFn((localKeyId: string) => store.keys.has(localKeyId)), + deleteKey: asyncFn((localKeyId: string) => { + store.keys.delete(localKeyId); + }), + listRecords: asyncFn(() => store.records.map(record => ({ ...record }))), + saveRecord: asyncFn((record: ExpoBiometricsRecord, options: { removeOtherRecordsForApp: boolean }) => { + store.records = store.records.filter( + existing => + existing.id !== record.id && + !(options.removeOtherRecordsForApp && existing.appIdentifier === record.appIdentifier), + ); + store.records.push(record); + }), + deleteRecord: asyncFn((localKeyId: string) => { + store.keys.delete(localKeyId); + store.records = store.records.filter(record => record.localKeyId !== localKeyId); + }), + ensureInstallationMarker: asyncFn(() => ({ wiped: false })), + ...(withHash ? { hashIdentifierHint: vi.fn(hashHint) } : {}), + }; + return module; +} -vi.mock('../../utils/native-module', () => ({ - get ClerkExpoModule() { - return mocks.isNativeModuleInstalled ? mocks.nativeModule : null; - }, -})); +function addLocalCredential(overrides: Partial = {}, { withKey = true } = {}) { + const record = localRecord(overrides); + biometrics.store.records.push(record); + if (withKey) { + biometrics.store.keys.add(record.localKeyId); + } + return record; +} -vi.mock('react-native', () => ({ - Platform: { - OS: 'ios', - }, -})); +function serverCredential(overrides: Record = {}) { + return { + id: 'td_1', + platform: 'ios', + appIdentifier: APP_IDENTIFIER, + name: "Sean's iPhone", + algorithm: 'ES256', + status: 'active', + createdAt: new Date(1_700_000_000_000), + updatedAt: new Date(1_700_000_100_000), + lastUsedAt: null, + revokedAt: null, + ...overrides, + }; +} + +function apiError(code: string, paramName?: string) { + return new ClerkAPIResponseError(code, { + data: [{ code, message: code, long_message: code, meta: paramName ? { param_name: paramName } : {} }], + status: 422, + }); +} + +function createClerk() { + const user = { + id: 'user_1', + __experimental_getBiometricCredentials: asyncFn(() => [serverCredential()]), + __experimental_prepareBiometricCredential: asyncFn(() => ({ + challenge: 'challenge', + challengeId: 'challenge_1', + trustedDeviceId: null, + clientData: 'enrollment-client-data', + expiresAt: null, + algorithm: 'ES256', + })), + __experimental_attemptBiometricCredential: asyncFn(() => serverCredential({ id: 'td_new' })), + __experimental_revokeBiometricCredential: asyncFn((id: string) => + serverCredential({ id, status: 'revoked', revokedAt: new Date(1_700_000_300_000) }), + ), + }; + const completedSignIn = { id: 'sia_1', status: 'complete', createdSessionId: 'sess_new' }; + const createdSignIn = { + id: 'sia_1', + status: 'needs_first_factor', + createdSessionId: null, + firstFactorVerification: { trustedDeviceChallenge: { clientData: 'sign-in-client-data' } }, + attemptFirstFactor: asyncFn(() => completedSignIn), + }; + const clientSignIn = { create: asyncFn(() => createdSignIn) }; + const session = { id: 'sess_1', status: 'active', user }; + return { + user, + session, + createdSignIn, + completedSignIn, + clientSignIn, + instance: { + __internal_environment: { + authConfig: { nativeSettings: { apiEnabled: true, trustedDeviceSignInEnabled: true } }, + } as { authConfig: { nativeSettings: { apiEnabled: boolean; trustedDeviceSignInEnabled: boolean } | null } }, + session: null as typeof session | null, + user: null as typeof user | null, + client: { signIn: clientSignIn } as { signIn: typeof clientSignIn } | undefined, + setActive: vi.fn(), + }, + }; +} -const nativeBiometricCredential = { - id: 'td_123', - object: 'trusted_device' as const, - platform: 'ios' as const, - appIdentifier: 'com.example.app', - name: "Sean's iPhone", - algorithm: 'ES256' as const, - status: 'active' as const, - createdAt: 1_700_000_000_000, - updatedAt: 1_700_000_100_000, - lastUsedAt: 1_700_000_200_000, - revokedAt: null, -}; +let biometrics: ReturnType; +let clerk: ReturnType; + +function signInClerkUser(status = 'active') { + clerk.session.status = status; + clerk.instance.session = clerk.session; + clerk.instance.user = clerk.user; +} function renderBiometricCredentials(useHook = useBiometricCredentialsOnIos) { return renderHook(() => useHook()).result.current; } beforeEach(() => { - mocks.idle.mockResolvedValue(undefined); - mocks.pullFromNative.mockResolvedValue(undefined); - mocks.useClerk.mockReturnValue({ - client: { signIn: mocks.jsSignIn, signedInSessions: mocks.jsSignedInSessions }, - setActive: mocks.setActive, - }); - Object.assign(mocks.jsSignIn, { - id: 'sia_123', - status: 'complete', - createdSessionId: 'sess_123', - }); - mocks.jsSignedInSessions.splice(0, mocks.jsSignedInSessions.length, { id: 'sess_123' }); + vi.clearAllMocks(); + mocks.platform.OS = 'ios'; + biometrics = createExpoBiometrics(); + clerk = createClerk(); + mocks.loadExpoBiometrics.mockImplementation(() => biometrics); + mocks.useClerk.mockImplementation(() => clerk.instance); }); -afterEach(() => { - vi.useRealTimers(); -}); +describe('getAvailability', () => { + test.each([ + ['environment_unavailable', null], + ['native_api_disabled', { apiEnabled: false, trustedDeviceSignInEnabled: true }], + ['feature_disabled', { apiEnabled: true, trustedDeviceSignInEnabled: false }], + ] as const)('reports %s from the environment before reading local records', async (reason, nativeSettings) => { + clerk.instance.__internal_environment.authConfig.nativeSettings = nativeSettings; + addLocalCredential(); + + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: false, + unavailableReason: reason, + }); + expect(biometrics.listRecords).not.toHaveBeenCalled(); + }); -describe('useBiometricCredentials on iOS', () => { - beforeEach(() => { - vi.clearAllMocks(); + test('reports no local credential when the device has none for this app', async () => { + addLocalCredential({ appIdentifier: 'com.example.other' }); + + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: false, + unavailableReason: 'no_local_credential', + }); }); - test('checks availability for an optional credential selector', async () => { - mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({ + test('is available for a signed-out user with a usable local credential', async () => { + addLocalCredential(); + + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ isAvailable: true, unavailableReason: null, }); + expect(clerk.user.__experimental_getBiometricCredentials).not.toHaveBeenCalled(); + }); + test('filters by credential ID', async () => { + addLocalCredential(); const biometricCredentials = renderBiometricCredentials(); - const availability = await biometricCredentials.getAvailability({ - id: 'td_123', - identifierHint: 'sean@example.com', + + await expect(biometricCredentials.getAvailability({ id: 'td_1' })).resolves.toMatchObject({ isAvailable: true }); + await expect(biometricCredentials.getAvailability({ id: 'td_other' })).resolves.toEqual({ + isAvailable: false, + unavailableReason: 'no_local_credential', }); + }); - expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledWith('td_123', 'sean@example.com'); - expect(availability).toEqual({ isAvailable: true, unavailableReason: null }); + test('matches the identifier hint by its normalized hash', async () => { + addLocalCredential({ identifierHintSha256: hashHint('sean@example.com') }); + const biometricCredentials = renderBiometricCredentials(); + + await expect( + biometricCredentials.getAvailability({ identifierHint: ' Sean@Example.com ' }), + ).resolves.toMatchObject({ isAvailable: true }); + expect(biometrics.hashIdentifierHint).toHaveBeenCalledWith('sean@example.com'); + await expect(biometricCredentials.getAvailability({ identifierHint: 'other@example.com' })).resolves.toEqual({ + isAvailable: false, + unavailableReason: 'no_local_credential', + }); }); - test('waits for native client synchronization before checking availability', async () => { - let finishNativeSync!: () => void; - const nativeSync = new Promise(resolve => { - finishNativeSync = resolve; + test('falls back to the raw identifier hint when the module cannot hash hints', async () => { + biometrics = createExpoBiometrics({ withHash: false }); + const { identifierHintSha256: _, ...legacyRecord } = localRecord({ identifierHint: 'sean@example.com' }); + biometrics.store.records.push(legacyRecord); + biometrics.store.keys.add(legacyRecord.localKeyId); + const biometricCredentials = renderBiometricCredentials(); + + await expect(biometricCredentials.getAvailability({ identifierHint: 'SEAN@example.com' })).resolves.toMatchObject({ + isAvailable: true, }); - mocks.idle.mockReturnValueOnce(nativeSync); - mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({ + await expect(biometricCredentials.getAvailability({ identifierHint: 'other@example.com' })).resolves.toMatchObject({ + unavailableReason: 'no_local_credential', + }); + }); + + test('ignores a blank identifier hint', async () => { + addLocalCredential({ identifierHintSha256: hashHint('sean@example.com') }); + + await expect(renderBiometricCredentials().getAvailability({ identifierHint: ' ' })).resolves.toMatchObject({ isAvailable: true, - unavailableReason: null, }); + expect(biometrics.hashIdentifierHint).not.toHaveBeenCalled(); + }); + + test('prunes local records whose key is missing', async () => { + addLocalCredential({ id: 'td_orphan', localKeyId: 'key_orphan' }, { withKey: false }); + biometrics.hasKey.mockRejectedValueOnce(moduleError('key_not_found')); - const availability = renderBiometricCredentials().getAvailability(); - await Promise.resolve(); + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: false, + unavailableReason: 'local_key_missing', + }); + expect(biometrics.deleteRecord).toHaveBeenCalledWith('key_orphan'); + expect(biometrics.store.records).toEqual([]); + }); - expect(mocks.nativeModule.getTrustedDeviceAvailability).not.toHaveBeenCalled(); + test('keeps usable records while pruning the ones without a key', async () => { + addLocalCredential({ id: 'td_orphan', localKeyId: 'key_orphan' }, { withKey: false }); + addLocalCredential(); - finishNativeSync(); - await expect(availability).resolves.toEqual({ isAvailable: true, unavailableReason: null }); - expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledTimes(1); + await expect(renderBiometricCredentials().getAvailability()).resolves.toMatchObject({ isAvailable: true }); + expect(biometrics.store.records.map(record => record.id)).toEqual(['td_1']); }); - test('rejects availability when native client synchronization times out', async () => { - mocks.idle.mockRejectedValueOnce(Object.assign(new Error('timed out'), { code: 'environment_unavailable' })); + test('reports biometric authentication unavailable when no record policy can be evaluated', async () => { + addLocalCredential(); + biometrics.getAvailability.mockResolvedValue({ + biometryType: 'none', + canEvaluateBiometrics: false, + canEvaluateDeviceOwner: true, + errorCode: null, + }); - await expect(renderBiometricCredentials().getAvailability()).rejects.toMatchObject({ - code: 'environment_unavailable', + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: false, + unavailableReason: 'biometric_authentication_unavailable', }); - expect(mocks.nativeModule.getTrustedDeviceAvailability).not.toHaveBeenCalled(); }); - test('lists biometric credentials and converts native timestamps to dates', async () => { - mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeBiometricCredential]); + test('allows passcode-backed records when only device owner authentication is available', async () => { + addLocalCredential({ policy: 'biometry_or_device_passcode' }); + biometrics.getAvailability.mockResolvedValue({ + biometryType: 'none', + canEvaluateBiometrics: false, + canEvaluateDeviceOwner: true, + errorCode: null, + }); - const [biometricCredential] = await renderBiometricCredentials().list(); + await expect(renderBiometricCredentials().getAvailability()).resolves.toMatchObject({ isAvailable: true }); + }); - expect(biometricCredential).toEqual({ - ...nativeBiometricCredential, - createdAt: new Date(nativeBiometricCredential.createdAt), - updatedAt: new Date(nativeBiometricCredential.updatedAt), - lastUsedAt: new Date(nativeBiometricCredential.lastUsedAt), - revokedAt: null, + describe('with an active session', () => { + beforeEach(() => { + signInClerkUser(); }); - }); - test('maps omitted optional native timestamps to null', async () => { - mocks.nativeModule.listTrustedDevices.mockResolvedValue([ - { - ...nativeBiometricCredential, - lastUsedAt: undefined, - revokedAt: undefined, - }, - ]); + test('is available when the server credential is active', async () => { + addLocalCredential(); - const [biometricCredential] = await renderBiometricCredentials().list(); + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: true, + unavailableReason: null, + }); + expect(clerk.user.__experimental_getBiometricCredentials).toHaveBeenCalledOnce(); + }); - expect(biometricCredential.lastUsedAt).toBeNull(); - expect(biometricCredential.revokedAt).toBeNull(); - }); + test('only considers records enrolled by the active user', async () => { + addLocalCredential({ userId: 'user_other' }); + + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: false, + unavailableReason: 'no_local_credential', + }); + expect(clerk.user.__experimental_getBiometricCredentials).not.toHaveBeenCalled(); + }); + + test('deletes a local record the server no longer has', async () => { + addLocalCredential(); + clerk.user.__experimental_getBiometricCredentials.mockResolvedValue([]); - test('waits for native client synchronization before listing biometric credentials', async () => { - let finishNativeSync!: () => void; - const nativeSync = new Promise(resolve => { - finishNativeSync = resolve; + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: false, + unavailableReason: 'server_credential_missing', + }); + expect(biometrics.deleteRecord).toHaveBeenCalledWith('key_1'); }); - mocks.idle.mockReturnValueOnce(nativeSync); - mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeBiometricCredential]); - const listing = renderBiometricCredentials().list(); - await Promise.resolve(); + test('deletes a local record whose server credential is revoked', async () => { + addLocalCredential(); + clerk.user.__experimental_getBiometricCredentials.mockResolvedValue([serverCredential({ status: 'revoked' })]); - expect(mocks.nativeModule.listTrustedDevices).not.toHaveBeenCalled(); + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: false, + unavailableReason: 'server_credential_revoked', + }); + expect(biometrics.store.records).toEqual([]); + }); + + test('falls through to an older record that is still active on the server', async () => { + addLocalCredential({ id: 'td_old', localKeyId: 'key_old', createdAt: 1 }); + addLocalCredential({ id: 'td_new', localKeyId: 'key_new', createdAt: 2 }); + clerk.user.__experimental_getBiometricCredentials.mockResolvedValue([serverCredential({ id: 'td_old' })]); - finishNativeSync(); - await expect(listing).resolves.toHaveLength(1); - expect(mocks.nativeModule.listTrustedDevices).toHaveBeenCalledTimes(1); + await expect(renderBiometricCredentials().getAvailability()).resolves.toMatchObject({ isAvailable: true }); + expect(biometrics.store.records.map(record => record.id)).toEqual(['td_old']); + }); + + test('does not reconcile a pending session', async () => { + signInClerkUser('pending'); + addLocalCredential(); + + await expect(renderBiometricCredentials().getAvailability()).resolves.toMatchObject({ isAvailable: true }); + expect(clerk.user.__experimental_getBiometricCredentials).not.toHaveBeenCalled(); + }); + + test('surfaces server errors unchanged', async () => { + addLocalCredential(); + const error = apiError('session_reverification_required'); + clerk.user.__experimental_getBiometricCredentials.mockRejectedValue(error); + + await expect(renderBiometricCredentials().getAvailability()).rejects.toBe(error); + expect(biometrics.store.records).toHaveLength(1); + }); }); - test('returns stable operation identities', () => { - const { result, rerender } = renderHook(() => useBiometricCredentialsOnIos()); - const initialBiometricCredentials = result.current; + test('maps module failures to the operation error code', async () => { + biometrics.listRecords.mockRejectedValue(moduleError('storage_failed', 'Keychain unavailable')); - rerender(); + const operation = renderBiometricCredentials().getAvailability(); - expect(result.current).toBe(initialBiometricCredentials); + await expect(operation).rejects.toMatchObject({ + code: 'E_TRUSTED_DEVICE_AVAILABILITY_FAILED', + message: 'Keychain unavailable', + }); }); +}); - test('enrolls with the safe default authentication policy', async () => { - mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeBiometricCredential); +describe('list', () => { + test('lists the signed-in user credentials', async () => { + signInClerkUser(); + clerk.user.__experimental_getBiometricCredentials.mockResolvedValue([ + serverCredential({ lastUsedAt: new Date(1_700_000_200_000) }), + ]); - const biometricCredential = await renderBiometricCredentials().enroll({ + await expect(renderBiometricCredentials().list()).resolves.toEqual([ + { + id: 'td_1', + object: 'trusted_device', + platform: 'ios', + appIdentifier: APP_IDENTIFIER, + name: "Sean's iPhone", + algorithm: 'ES256', + status: 'active', + createdAt: new Date(1_700_000_000_000), + updatedAt: new Date(1_700_000_100_000), + lastUsedAt: new Date(1_700_000_200_000), + revokedAt: null, + }, + ]); + }); + + test('normalizes unknown resource values', async () => { + signInClerkUser(); + clerk.user.__experimental_getBiometricCredentials.mockResolvedValue([ + serverCredential({ platform: 'visionos', algorithm: 'ES384', status: 'pending_review', lastUsedAt: undefined }), + ]); + + const [credential] = await renderBiometricCredentials().list(); + + expect(credential).toMatchObject({ platform: 'unknown', algorithm: 'ES384', status: 'unknown', lastUsedAt: null }); + }); + + test('requires a signed-in user', async () => { + await expect(renderBiometricCredentials().list()).rejects.toMatchObject({ code: 'E_TRUSTED_DEVICE_LIST_FAILED' }); + }); +}); + +describe('enroll', () => { + beforeEach(() => { + signInClerkUser(); + }); + + test('creates a key, completes the server enrollment and saves the local record', async () => { + const credential = await renderBiometricCredentials().enroll({ name: "Sean's iPhone", - identifierHint: 'sean@example.com', + identifierHint: ' Sean@Example.com ', reason: 'Use Face ID to trust this device.', }); - expect(mocks.nativeModule.enrollTrustedDevice).toHaveBeenCalledWith( - "Sean's iPhone", - 'sean@example.com', + const publicKeyJwk = '{"kty":"EC","crv":"P-256","x":"x","y":"y","alg":"ES256"}'; + expect(biometrics.createKey).toHaveBeenCalledWith('biometry_current_set'); + expect(clerk.user.__experimental_prepareBiometricCredential).toHaveBeenCalledWith({ + platform: 'ios', + appIdentifier: APP_IDENTIFIER, + name: "Sean's iPhone", + algorithm: 'ES256', + publicKeyJwk, + }); + expect(biometrics.sign).toHaveBeenCalledWith( + 'key_new', + 'enrollment-client-data', 'Use Face ID to trust this device.', - 'biometry_current_set', ); - expect(biometricCredential.createdAt).toEqual(new Date(nativeBiometricCredential.createdAt)); + expect(clerk.user.__experimental_attemptBiometricCredential).toHaveBeenCalledWith({ + platform: 'ios', + appIdentifier: APP_IDENTIFIER, + name: "Sean's iPhone", + algorithm: 'ES256', + publicKeyJwk, + clientData: 'enrollment-client-data', + signature: 'signature', + }); + expect(biometrics.saveRecord).toHaveBeenCalledWith( + { + id: 'td_new', + localKeyId: 'key_new', + userId: 'user_1', + appIdentifier: APP_IDENTIFIER, + identifierHint: 'sean@example.com', + policy: 'biometry_current_set', + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_100_000, + }, + { removeOtherRecordsForApp: true }, + ); + expect(credential).toMatchObject({ id: 'td_new', object: 'trusted_device', status: 'active' }); }); - test('waits for native client synchronization before enrollment', async () => { - let finishNativeSync!: () => void; - const nativeSync = new Promise(resolve => { - finishNativeSync = resolve; - }); - mocks.idle.mockReturnValueOnce(nativeSync); - mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeBiometricCredential); + test('replaces the other local records for the app', async () => { + addLocalCredential({ id: 'td_old', localKeyId: 'key_old', userId: 'user_other' }); + + await renderBiometricCredentials().enroll(); + + expect(biometrics.store.records.map(record => record.id)).toEqual(['td_new']); + }); - const enrollment = renderBiometricCredentials().enroll(); - await Promise.resolve(); + test('uses the requested policy, platform and default prompt', async () => { + mocks.platform.OS = 'android'; - expect(mocks.nativeModule.enrollTrustedDevice).not.toHaveBeenCalled(); + await renderBiometricCredentials(useBiometricCredentialsOnAndroid).enroll({ + policy: 'biometry_or_device_passcode', + }); - finishNativeSync(); - await expect(enrollment).resolves.toMatchObject({ id: 'td_123' }); - expect(mocks.nativeModule.enrollTrustedDevice).toHaveBeenCalledTimes(1); + expect(biometrics.createKey).toHaveBeenCalledWith('biometry_or_device_passcode'); + expect(clerk.user.__experimental_prepareBiometricCredential).toHaveBeenCalledWith( + expect.not.objectContaining({ name: expect.anything() }), + ); + expect(clerk.user.__experimental_prepareBiometricCredential).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'android' }), + ); + expect(biometrics.sign).toHaveBeenCalledWith( + 'key_new', + 'enrollment-client-data', + 'Use biometrics to enroll this device.', + ); + expect(biometrics.saveRecord).toHaveBeenCalledWith( + expect.not.objectContaining({ identifierHint: expect.anything() }), + { removeOtherRecordsForApp: true }, + ); }); - test('revokes a biometric credential by ID', async () => { - mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({ - ...nativeBiometricCredential, - status: 'revoked', - revokedAt: 1_700_000_300_000, + test('requires an active or pending session', async () => { + clerk.instance.session = null; + + await expect(renderBiometricCredentials().enroll()).rejects.toMatchObject({ + code: 'E_TRUSTED_DEVICE_ENROLLMENT_FAILED', }); + expect(biometrics.createKey).not.toHaveBeenCalled(); + }); - const biometricCredential = await renderBiometricCredentials().revoke('td_123'); + test('enrolls with a pending session', async () => { + signInClerkUser('pending'); - expect(mocks.nativeModule.revokeTrustedDevice).toHaveBeenCalledWith('td_123'); - expect(biometricCredential.status).toBe('revoked'); - expect(biometricCredential.revokedAt).toEqual(new Date(1_700_000_300_000)); + await expect(renderBiometricCredentials().enroll()).resolves.toMatchObject({ id: 'td_new' }); }); - test('waits for native client synchronization before revoking a biometric credential', async () => { - let finishNativeSync!: () => void; - const nativeSync = new Promise(resolve => { - finishNativeSync = resolve; - }); - mocks.idle.mockReturnValueOnce(nativeSync); - mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({ - ...nativeBiometricCredential, - status: 'revoked', + test('requires the feature to be enabled', async () => { + clerk.instance.__internal_environment.authConfig.nativeSettings = { + apiEnabled: true, + trustedDeviceSignInEnabled: false, + }; + + await expect(renderBiometricCredentials().enroll()).rejects.toMatchObject({ + code: 'E_TRUSTED_DEVICE_ENROLLMENT_FAILED', }); + expect(biometrics.createKey).not.toHaveBeenCalled(); + }); - const revocation = renderBiometricCredentials().revoke('td_123'); - await Promise.resolve(); + test('maps key creation failures without calling the server', async () => { + biometrics.createKey.mockRejectedValue(moduleError('biometry_not_enrolled')); - expect(mocks.nativeModule.revokeTrustedDevice).not.toHaveBeenCalled(); + await expect(renderBiometricCredentials().enroll()).rejects.toMatchObject({ + code: 'biometric_authentication_unavailable', + }); + expect(clerk.user.__experimental_prepareBiometricCredential).not.toHaveBeenCalled(); + }); - finishNativeSync(); - await expect(revocation).resolves.toMatchObject({ id: 'td_123', status: 'revoked' }); - expect(mocks.nativeModule.revokeTrustedDevice).toHaveBeenCalledWith('td_123'); + test('surfaces reverification errors from prepare unchanged and deletes the key', async () => { + const error = apiError('session_reverification_required'); + clerk.user.__experimental_prepareBiometricCredential.mockRejectedValue(error); + + await expect(renderBiometricCredentials().enroll()).rejects.toBe(error); + expect(biometrics.deleteKey).toHaveBeenCalledWith('key_new'); + expect(biometrics.sign).not.toHaveBeenCalled(); + expect(biometrics.saveRecord).not.toHaveBeenCalled(); }); - test('signs in through the native one-shot biometric-credential flow', async () => { - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_123', - status: 'complete', - createdSessionId: 'sess_123', - }); + test('deletes the key when signing is canceled', async () => { + biometrics.sign.mockRejectedValue(moduleError('user_canceled')); - const result = await renderBiometricCredentials().signIn({ - identifierHint: 'sean@example.com', - reason: 'Use Face ID to sign in.', + await expect(renderBiometricCredentials().enroll()).rejects.toMatchObject({ + code: 'biometric_authentication_canceled', }); + expect(biometrics.deleteKey).toHaveBeenCalledWith('key_new'); + expect(clerk.user.__experimental_attemptBiometricCredential).not.toHaveBeenCalled(); + }); - expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledWith( - null, - 'sean@example.com', - 'Use Face ID to sign in.', - ); - expect(result).toMatchObject({ - status: 'complete', - createdSessionId: 'sess_123', - signIn: mocks.jsSignIn, - }); - expect(result.setActive).toBe(mocks.setActive); + test('deletes the key when the server rejects the attempt', async () => { + const error = apiError('form_param_invalid', 'signature'); + clerk.user.__experimental_attemptBiometricCredential.mockRejectedValue(error); + + await expect(renderBiometricCredentials().enroll()).rejects.toBe(error); + expect(biometrics.deleteKey).toHaveBeenCalledWith('key_new'); + expect(biometrics.saveRecord).not.toHaveBeenCalled(); + expect(clerk.user.__experimental_revokeBiometricCredential).not.toHaveBeenCalled(); }); - test('keeps a completed native result authoritative when the current JS sign-in changes', async () => { - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_native', - status: 'complete', - createdSessionId: 'sess_123', - }); - Object.assign(mocks.jsSignIn, { - id: 'sia_other', - status: 'needs_second_factor', - createdSessionId: 'sess_other', + test('revokes the server credential and deletes the key when the record cannot be saved', async () => { + biometrics.saveRecord.mockRejectedValue(moduleError('storage_failed', 'Keychain write failed')); + + await expect(renderBiometricCredentials().enroll()).rejects.toMatchObject({ + code: 'E_TRUSTED_DEVICE_ENROLLMENT_FAILED', + message: 'Keychain write failed', }); + expect(clerk.user.__experimental_revokeBiometricCredential).toHaveBeenCalledWith('td_new'); + expect(biometrics.deleteKey).toHaveBeenCalledWith('key_new'); + }); - const result = await renderBiometricCredentials().signIn(); + test('keeps the save error when cleanup also fails', async () => { + biometrics.saveRecord.mockRejectedValue(moduleError('storage_failed')); + clerk.user.__experimental_revokeBiometricCredential.mockRejectedValue(new Error('offline')); + biometrics.deleteKey.mockRejectedValue(moduleError('unknown')); - expect(result).toMatchObject({ - status: 'complete', - createdSessionId: 'sess_123', - signIn: mocks.jsSignIn, + await expect(renderBiometricCredentials().enroll()).rejects.toMatchObject({ + code: 'E_TRUSTED_DEVICE_ENROLLMENT_FAILED', }); }); +}); - test('accepts a completed sign-in when the synchronized client only contains its session', async () => { - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_123', - status: 'complete', - createdSessionId: 'sess_123', - }); - Object.assign(mocks.jsSignIn, { - id: '', - status: null, - createdSessionId: null, - }); +describe('revoke', () => { + beforeEach(() => { + signInClerkUser(); + }); - const result = await renderBiometricCredentials().signIn(); + test('revokes on the server, then deletes the matching local credential', async () => { + addLocalCredential(); + addLocalCredential({ id: 'td_other', localKeyId: 'key_other' }); - expect(result).toMatchObject({ - status: 'complete', - createdSessionId: 'sess_123', - signIn: mocks.jsSignIn, - }); + const credential = await renderBiometricCredentials().revoke('td_1'); + + expect(clerk.user.__experimental_revokeBiometricCredential).toHaveBeenCalledWith('td_1'); + expect(biometrics.deleteRecord).toHaveBeenCalledWith('key_1'); + expect(biometrics.store.records.map(record => record.id)).toEqual(['td_other']); + expect(credential).toMatchObject({ id: 'td_1', status: 'revoked', revokedAt: new Date(1_700_000_300_000) }); }); - test('rejects a completed sign-in when its session is absent after synchronization', async () => { - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_123', - status: 'complete', - createdSessionId: 'sess_missing', - }); + test('keeps local state when the server revocation fails', async () => { + addLocalCredential(); + const error = apiError('session_reverification_required'); + clerk.user.__experimental_revokeBiometricCredential.mockRejectedValue(error); - await expect(renderBiometricCredentials().signIn()).rejects.toThrow( - 'Unable to synchronize biometric sign-in with the Clerk JS client: the created session is missing.', - ); + await expect(renderBiometricCredentials().revoke('td_1')).rejects.toBe(error); + expect(biometrics.deleteRecord).not.toHaveBeenCalled(); + }); + + test('returns the revoked credential when local cleanup fails', async () => { + addLocalCredential(); + biometrics.deleteRecord.mockRejectedValue(moduleError('storage_failed')); + + await expect(renderBiometricCredentials().revoke('td_1')).resolves.toMatchObject({ status: 'revoked' }); }); +}); + +describe('signIn', () => { + test('signs the trusted device challenge and returns the completed sign-in', async () => { + addLocalCredential(); + + const result = await renderBiometricCredentials().signIn({ reason: 'Use Face ID to sign in.' }); - test('waits for native client synchronization before biometric sign-in', async () => { - let finishNativeSync!: () => void; - const nativeSync = new Promise(resolve => { - finishNativeSync = resolve; + expect(clerk.clientSignIn.create).toHaveBeenCalledWith({ strategy: 'trusted_device', trustedDeviceId: 'td_1' }); + expect(biometrics.sign).toHaveBeenCalledWith('key_1', 'sign-in-client-data', 'Use Face ID to sign in.'); + expect(clerk.createdSignIn.attemptFirstFactor).toHaveBeenCalledWith({ + strategy: 'trusted_device', + trustedDeviceId: 'td_1', + clientData: 'sign-in-client-data', + signature: 'signature', + algorithm: 'ES256', }); - mocks.idle.mockReturnValueOnce(nativeSync); - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_123', + expect(result).toEqual({ status: 'complete', - createdSessionId: 'sess_123', + createdSessionId: 'sess_new', + signIn: clerk.completedSignIn, + setActive: clerk.instance.setActive, }); + expect(clerk.instance.setActive).not.toHaveBeenCalled(); + expect(mocks.idle).not.toHaveBeenCalled(); + expect(mocks.pullFromNative).not.toHaveBeenCalled(); + }); - const signIn = renderBiometricCredentials().signIn(); - await Promise.resolve(); + test('uses the default prompt and the newest matching credential', async () => { + addLocalCredential({ id: 'td_old', localKeyId: 'key_old', createdAt: 1 }); + addLocalCredential({ id: 'td_new', localKeyId: 'key_new', createdAt: 2 }); - expect(mocks.nativeModule.signInWithTrustedDevice).not.toHaveBeenCalled(); + await renderBiometricCredentials().signIn(); - finishNativeSync(); - await expect(signIn).resolves.toMatchObject({ status: 'complete', createdSessionId: 'sess_123' }); - expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledTimes(1); + expect(clerk.clientSignIn.create).toHaveBeenCalledWith({ strategy: 'trusted_device', trustedDeviceId: 'td_new' }); + expect(biometrics.sign).toHaveBeenCalledWith('key_new', 'sign-in-client-data', 'Use biometrics to sign in.'); }); - test.each([ - ['needs_second_factor', 'prepareSecondFactor'], - ['needs_client_trust', 'attemptSecondFactor'], - ['needs_new_password', 'resetPassword'], - ] as const)('returns a continuable JS sign-in for %s', async (status, continuationMethod) => { - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_mfa', - status, - createdSessionId: null, - }); - mocks.pullFromNative.mockImplementation(() => { - Object.assign(mocks.jsSignIn, { - id: 'sia_mfa', - status, - createdSessionId: null, - }); - return Promise.resolve(); + test('selects the credential matching the identifier hint', async () => { + addLocalCredential({ id: 'td_sean', localKeyId: 'key_sean', identifierHintSha256: hashHint('sean@example.com') }); + addLocalCredential({ + id: 'td_ana', + localKeyId: 'key_ana', + createdAt: 2, + identifierHintSha256: hashHint('ana@x.co'), }); - const result = await renderBiometricCredentials().signIn(); + await renderBiometricCredentials().signIn({ identifierHint: 'Sean@example.com' }); - expect(result).toMatchObject({ - status, - createdSessionId: null, - signIn: mocks.jsSignIn, - }); - expect(result.signIn[continuationMethod]).toBe(mocks.jsSignIn[continuationMethod]); + expect(clerk.clientSignIn.create).toHaveBeenCalledWith({ strategy: 'trusted_device', trustedDeviceId: 'td_sean' }); }); - test('does not resolve a completed sign-in before native-to-JS synchronization', async () => { - let finishSync!: () => void; - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_123', - status: 'complete', - createdSessionId: 'sess_123', - }); - mocks.pullFromNative.mockReturnValue( - new Promise(resolve => { - finishSync = resolve; - }), - ); + test.each(['needs_second_factor', 'needs_client_trust', 'needs_new_password'])( + 'returns a continuable sign-in for %s', + async status => { + addLocalCredential(); + const continuable = { id: 'sia_1', status, createdSessionId: null, prepareSecondFactor: vi.fn() }; + clerk.createdSignIn.attemptFirstFactor.mockResolvedValue(continuable as never); - let didResolve = false; - const signIn = renderBiometricCredentials() - .signIn() - .then(result => { - didResolve = true; - return result; + await expect(renderBiometricCredentials().signIn()).resolves.toMatchObject({ + status, + createdSessionId: null, + signIn: continuable, }); - await vi.waitFor(() => expect(mocks.pullFromNative).toHaveBeenCalled()); - expect(didResolve).toBe(false); + }, + ); + + test('rejects without contacting the server when no local credential is available', async () => { + const operation = renderBiometricCredentials().signIn(); - finishSync(); - await expect(signIn).resolves.toMatchObject({ createdSessionId: 'sess_123' }); + await expect(operation).rejects.toMatchObject({ code: 'E_TRUSTED_DEVICE_SIGN_IN_FAILED' }); + await expect(operation).rejects.toThrow('no_local_credential'); + expect(clerk.clientSignIn.create).not.toHaveBeenCalled(); }); - test('rejects when native-to-JS synchronization returns a different sign-in attempt', async () => { - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_native', - status: 'needs_second_factor', - createdSessionId: null, - }); - Object.assign(mocks.jsSignIn, { - id: 'sia_js', - status: 'needs_second_factor', - createdSessionId: null, - }); + test('rejects when the Clerk client is unavailable', async () => { + addLocalCredential(); + clerk.instance.client = undefined; - await expect(renderBiometricCredentials().signIn()).rejects.toThrow( - 'Unable to synchronize biometric sign-in with the Clerk JS client: the sign-in attempt does not match.', - ); + await expect(renderBiometricCredentials().signIn()).rejects.toThrow('client sign-in resource is unavailable'); }); - test('uses the Clerk instance from the React provider after synchronization', async () => { - const providerSetActive = vi.fn(); - const providerSignIn = { - ...mocks.jsSignIn, - id: 'sia_provider', - }; - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_provider', - status: 'complete', - createdSessionId: 'sess_provider', - }); - mocks.useClerk.mockReturnValue({ - client: { - signIn: providerSignIn, - signedInSessions: [{ id: 'sess_provider' }], - }, - setActive: providerSetActive, - }); + test('rejects when the server does not return a challenge', async () => { + addLocalCredential(); + clerk.createdSignIn.firstFactorVerification.trustedDeviceChallenge = undefined as never; - const result = await renderBiometricCredentials().signIn(); + await expect(renderBiometricCredentials().signIn()).rejects.toThrow('did not return a challenge'); + expect(biometrics.sign).not.toHaveBeenCalled(); + }); + + test.each(['form_resource_not_found', 'trusted_device_not_registered'])( + 'forgets the local credential when sign-in creation fails with %s', + async code => { + addLocalCredential(); + const error = apiError(code, 'trusted_device_id'); + clerk.clientSignIn.create.mockRejectedValue(error); + + await expect(renderBiometricCredentials().signIn()).rejects.toBe(error); + expect(biometrics.deleteRecord).toHaveBeenCalledWith('key_1'); + expect(biometrics.store.records).toEqual([]); + }, + ); + + test('forgets the local credential when the attempt reports it missing', async () => { + addLocalCredential(); + const error = apiError('trusted_device_not_registered', 'trusted_device_id'); + clerk.createdSignIn.attemptFirstFactor.mockRejectedValue(error); - expect(result.signIn).toBe(providerSignIn); - expect(result.setActive).toBe(providerSetActive); + await expect(renderBiometricCredentials().signIn()).rejects.toBe(error); + expect(biometrics.store.records).toEqual([]); }); - test('rejects when the Clerk JS client is unavailable after synchronization', async () => { - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_native', - status: 'complete', - createdSessionId: 'sess_native', - }); - mocks.useClerk.mockReturnValue({ - client: undefined, - setActive: mocks.setActive, - }); + test('keeps the local credential for unrelated server errors', async () => { + addLocalCredential(); + const error = apiError('form_resource_not_found', 'sign_in_id'); + clerk.createdSignIn.attemptFirstFactor.mockRejectedValue(error); - await expect(renderBiometricCredentials().signIn()).rejects.toThrow( - 'Unable to synchronize biometric sign-in with the Clerk JS client: the client sign-in resource is unavailable.', - ); + await expect(renderBiometricCredentials().signIn()).rejects.toBe(error); + expect(biometrics.deleteRecord).not.toHaveBeenCalled(); }); - test('normalizes unknown resource values and preserves the synchronized JS sign-in status', async () => { - mocks.nativeModule.listTrustedDevices.mockResolvedValue([ - { - ...nativeBiometricCredential, - platform: 'visionos', - algorithm: 'ES384', - status: 'pending_review', - }, - ]); - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_future', - status: 'future_sign_in_status', - createdSessionId: null, - }); - Object.assign(mocks.jsSignIn, { - id: 'sia_future', - status: 'future_sign_in_status', - createdSessionId: null, - }); + test.each(['key_invalidated', 'key_not_found'])( + 'forgets the local credential when signing fails with %s', + async code => { + addLocalCredential(); + biometrics.sign.mockRejectedValue(moduleError(code)); - const biometricCredentials = renderBiometricCredentials(); - const [device] = await biometricCredentials.list(); - const signIn = await biometricCredentials.signIn(); + const operation = renderBiometricCredentials().signIn(); - expect(device).toMatchObject({ - platform: 'unknown', - algorithm: 'ES384', - status: 'unknown', - }); - expect(signIn.status).toBe('future_sign_in_status'); - }); + await expect(operation).rejects.toMatchObject({ code }); + expect(biometrics.deleteRecord).toHaveBeenCalledWith('key_1'); + expect(clerk.createdSignIn.attemptFirstFactor).not.toHaveBeenCalled(); + }, + ); - test('preserves structured native errors', async () => { - const nativeError = Object.assign(new Error('Biometric authentication was canceled.'), { - code: 'biometric_authentication_canceled', - }); - mocks.nativeModule.signInWithTrustedDevice.mockRejectedValue(nativeError); + test('keeps the local credential when the prompt is canceled', async () => { + addLocalCredential(); + biometrics.sign.mockRejectedValue(moduleError('user_canceled', 'Canceled')); const operation = renderBiometricCredentials().signIn(); - await expect(operation).rejects.toBe(nativeError); + await expect(operation).rejects.toMatchObject({ code: 'biometric_authentication_canceled', message: 'Canceled' }); await operation.catch(error => { expect(isBiometricCredentialError(error)).toBe(true); - if (isBiometricCredentialError(error)) { - expect(error.code).toBe('biometric_authentication_canceled'); - } }); + expect(biometrics.deleteRecord).not.toHaveBeenCalled(); }); - test('explains how to install @clerk/expo-native-components when the native module is missing', async () => { - mocks.isNativeModuleInstalled = false; + test('reconciles with the server when a user is already signed in', async () => { + signInClerkUser(); + addLocalCredential(); + clerk.user.__experimental_getBiometricCredentials.mockResolvedValue([]); - try { - await expect(renderBiometricCredentials().signIn()).rejects.toThrow( - 'Biometric credentials require the @clerk/expo-native-components package in a development build. Install it with `npx expo install @clerk/expo-native-components`', - ); - } finally { - mocks.isNativeModuleInstalled = true; - } + await expect(renderBiometricCredentials().signIn()).rejects.toThrow('server_credential_missing'); + expect(biometrics.store.records).toEqual([]); + expect(clerk.clientSignIn.create).not.toHaveBeenCalled(); }); +}); - test('explains that the development client must contain the native methods', async () => { - const signInWithTrustedDevice = mocks.nativeModule.signInWithTrustedDevice; - Object.assign(mocks.nativeModule, { signInWithTrustedDevice: undefined }); +describe('missing @clerk/expo-biometrics', () => { + test.each(['getAvailability', 'list', 'enroll', 'revoke', 'signIn'] as const)( + '%s explains how to install the package', + async method => { + mocks.loadExpoBiometrics.mockReturnValue(null); + const biometricCredentials = renderBiometricCredentials(); - try { - await expect(renderBiometricCredentials().signIn()).rejects.toThrow( - 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native-components.', + await expect((biometricCredentials[method] as (arg?: string) => Promise)('td_1')).rejects.toThrow( + 'Biometric credentials require the @clerk/expo-biometrics package. Install it with `npx expo install @clerk/expo-biometrics`', ); - } finally { - Object.assign(mocks.nativeModule, { signInWithTrustedDevice }); - } - }); -}); - -describe('useBiometricCredentials on Android', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + }, + ); - test('uses the native biometric-credential bridge', async () => { - mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({ - isAvailable: true, - unavailableReason: null, - }); - mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ - id: 'sia_android', - status: 'complete', - createdSessionId: 'sess_android', + test('explains that the development build must include the native module', async () => { + biometrics.getAppIdentifier.mockImplementation(() => { + throw moduleError('native_module_unavailable'); }); - Object.assign(mocks.jsSignIn, { - id: 'sia_android', - status: 'complete', - createdSessionId: 'sess_android', - }); - mocks.jsSignedInSessions.splice(0, mocks.jsSignedInSessions.length, { id: 'sess_android' }); - - const biometricCredentials = renderBiometricCredentials(useBiometricCredentialsOnAndroid); - await expect(biometricCredentials.getAvailability({ identifierHint: 'sean@example.com' })).resolves.toEqual({ - isAvailable: true, - unavailableReason: null, - }); - await expect(biometricCredentials.signIn({ reason: 'Confirm your identity to sign in.' })).resolves.toMatchObject({ - status: 'complete', - createdSessionId: 'sess_android', - signIn: mocks.jsSignIn, - }); - expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledWith(null, 'sean@example.com'); - expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledWith( - null, - null, - 'Confirm your identity to sign in.', + await expect(renderBiometricCredentials().signIn()).rejects.toThrow( + 'Biometric credentials require a development build that includes @clerk/expo-biometrics', ); }); }); -describe('useBiometricCredentials on unsupported platforms', () => { - test('returns stable operation identities', () => { - const { result, rerender } = renderHook(() => useBiometricCredentialsOnUnsupportedPlatform()); - const biometricCredentials = result.current; +describe('useBiometricCredentials', () => { + test.each([ + ['iOS', useBiometricCredentialsOnIos], + ['Android', useBiometricCredentialsOnAndroid], + ['an unsupported platform', useBiometricCredentialsOnUnsupportedPlatform], + ])('returns stable operation identities on %s', (_, useHook) => { + const { result, rerender } = renderHook(() => useHook()); + const initial = result.current; rerender(); - expect(result.current).toBe(biometricCredentials); + expect(result.current).toBe(initial); }); - test('reports unsupported availability without invoking native code', async () => { + test('reports unsupported availability on unsupported platforms without loading the module', async () => { const biometricCredentials = renderBiometricCredentials(useBiometricCredentialsOnUnsupportedPlatform); - const availability = await biometricCredentials.getAvailability(); - expect(availability).toEqual({ + await expect(biometricCredentials.getAvailability()).resolves.toEqual({ isAvailable: false, unavailableReason: 'unsupported_platform', }); + expect(mocks.loadExpoBiometrics).not.toHaveBeenCalled(); }); - test('rejects operations that require the native implementation', async () => { + test('rejects operations on unsupported platforms', async () => { const biometricCredentials = renderBiometricCredentials(useBiometricCredentialsOnUnsupportedPlatform); await expect(biometricCredentials.enroll()).rejects.toThrow( diff --git a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts new file mode 100644 index 00000000000..7210f735e0a --- /dev/null +++ b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts @@ -0,0 +1,493 @@ +import type { useClerk } from '@clerk/react'; +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import type { + BiometricCredentialResource, + EnvironmentResource, + PrepareBiometricCredentialParams, + SignInResource, + UserResource, +} from '@clerk/shared/types'; +import { Platform } from 'react-native'; + +import { errorThrower } from '../utils/errors'; +import type { BiometricCredentialError, BiometricCredentialErrorCode } from './errors'; +import type { ExpoBiometricsModule, ExpoBiometricsRecord } from './loadExpoBiometrics'; +import { loadExpoBiometrics } from './loadExpoBiometrics'; +import { createNativeReverify } from './nativeReverification'; +import type { + BiometricCredential, + BiometricCredentialPlatform, + BiometricCredentialPolicy, + BiometricCredentialStatus, + BiometricCredentialUnavailableReason, + UseBiometricCredentialsReturn, +} from './types'; + +type Clerk = ReturnType; + +type OperationErrorCode = + | 'E_TRUSTED_DEVICE_AVAILABILITY_FAILED' + | 'E_TRUSTED_DEVICE_LIST_FAILED' + | 'E_TRUSTED_DEVICE_ENROLLMENT_FAILED' + | 'E_TRUSTED_DEVICE_REVOCATION_FAILED' + | 'E_TRUSTED_DEVICE_SIGN_IN_FAILED'; + +type Biometrics = { + module: ExpoBiometricsModule; + appIdentifier: string; + fallbackCode: OperationErrorCode; +}; + +type LocalCredentialSelection = + | { record: ExpoBiometricsRecord; unavailableReason?: never } + | { record?: never; unavailableReason: BiometricCredentialUnavailableReason }; + +const DEFAULT_POLICY: BiometricCredentialPolicy = 'biometry_current_set'; +const DEFAULT_ENROLLMENT_REASON = 'Use biometrics to enroll this device.'; +const DEFAULT_SIGN_IN_REASON = 'Use biometrics to sign in.'; + +const EXPO_BIOMETRICS_INSTALL_INSTRUCTIONS = + 'Install it with `npx expo install @clerk/expo-biometrics`, then rebuild your development build.'; + +const MISSING_CREDENTIAL_ERROR_CODES = new Set(['form_resource_not_found', 'trusted_device_not_registered']); +const TRUSTED_DEVICE_ID_PARAM = 'trusted_device_id'; + +const MODULE_ERROR_CODES: Record = { + user_canceled: 'biometric_authentication_canceled', + system_canceled: 'biometric_authentication_canceled', + user_fallback: 'biometric_authentication_canceled', + authentication_failed: 'biometric_authentication_failed', + biometry_not_available: 'biometric_authentication_unavailable', + biometry_not_enrolled: 'biometric_authentication_unavailable', + biometry_lockout: 'biometric_authentication_unavailable', + passcode_not_set: 'biometric_authentication_unavailable', + key_not_found: 'key_not_found', + key_invalidated: 'key_invalidated', + key_generation_failed: 'key_generation_failed', + signing_failed: 'signing_failed', + not_implemented: 'unsupported_platform', +}; + +function biometricCredentialError( + code: BiometricCredentialErrorCode, + message: string, + cause?: unknown, +): BiometricCredentialError { + return Object.assign(new Error(message), { code }, cause === undefined ? {} : { cause }); +} + +function moduleErrorCode(error: unknown): string | undefined { + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : undefined; +} + +function toBiometricCredentialError(error: unknown, fallbackCode: OperationErrorCode): BiometricCredentialError { + const code = moduleErrorCode(error); + const message = error instanceof Error ? error.message : String(error); + return biometricCredentialError((code && MODULE_ERROR_CODES[code]) || fallbackCode, message, error); +} + +async function callModule(biometrics: Biometrics, operation: (module: ExpoBiometricsModule) => T | Promise) { + try { + return await operation(biometrics.module); + } catch (error) { + throw toBiometricCredentialError(error, biometrics.fallbackCode); + } +} + +function requireBiometrics(fallbackCode: OperationErrorCode): Biometrics { + const module = loadExpoBiometrics(); + if (!module) { + return errorThrower.throw( + `Biometric credentials require the @clerk/expo-biometrics package. ${EXPO_BIOMETRICS_INSTALL_INSTRUCTIONS}`, + ); + } + try { + return { module, appIdentifier: module.getAppIdentifier(), fallbackCode }; + } catch (error) { + if (moduleErrorCode(error) === 'native_module_unavailable') { + return errorThrower.throw( + `Biometric credentials require a development build that includes @clerk/expo-biometrics, and are not available in Expo Go. ${EXPO_BIOMETRICS_INSTALL_INSTRUCTIONS}`, + ); + } + throw toBiometricCredentialError(error, fallbackCode); + } +} + +function normalizeIdentifierHint(identifierHint: string | null | undefined): string | null { + const normalized = identifierHint?.trim().toLowerCase(); + return normalized ? normalized : null; +} + +async function identifierHintMatcher( + biometrics: Biometrics, + identifierHint: string | undefined, +): Promise<(record: ExpoBiometricsRecord) => boolean> { + const normalizedHint = normalizeIdentifierHint(identifierHint); + if (!normalizedHint) { + return () => true; + } + const hash = + typeof biometrics.module.hashIdentifierHint === 'function' + ? await callModule(biometrics, module => module.hashIdentifierHint?.(normalizedHint) ?? null) + : null; + return record => { + if (hash && typeof record.identifierHintSha256 === 'string') { + return record.identifierHintSha256 === hash; + } + return normalizeIdentifierHint(record.identifierHint) === normalizedHint; + }; +} + +function newestFirst(a: ExpoBiometricsRecord, b: ExpoBiometricsRecord): number { + if (a.createdAt !== b.createdAt) { + return b.createdAt - a.createdAt; + } + if (a.updatedAt !== b.updatedAt) { + return b.updatedAt - a.updatedAt; + } + return a.id < b.id ? 1 : a.id > b.id ? -1 : 0; +} + +function toBiometricCredentialPlatform(platform: string): BiometricCredentialPlatform { + return platform === 'ios' || platform === 'android' ? platform : 'unknown'; +} + +function toBiometricCredentialStatus(status: string): BiometricCredentialStatus { + return status === 'active' || status === 'revoked' ? status : 'unknown'; +} + +function toBiometricCredential(credential: BiometricCredentialResource): BiometricCredential { + return { + id: credential.id, + object: 'trusted_device', + platform: toBiometricCredentialPlatform(credential.platform), + appIdentifier: credential.appIdentifier, + name: credential.name ?? null, + algorithm: credential.algorithm, + status: toBiometricCredentialStatus(credential.status), + createdAt: credential.createdAt, + updatedAt: credential.updatedAt, + lastUsedAt: credential.lastUsedAt ?? null, + revokedAt: credential.revokedAt ?? null, + }; +} + +function isMissingCredentialError(error: unknown): boolean { + return ( + isClerkAPIResponseError(error) && + error.errors.some( + apiError => + MISSING_CREDENTIAL_ERROR_CODES.has(apiError.code) && apiError.meta?.paramName === TRUSTED_DEVICE_ID_PARAM, + ) + ); +} + +export function createBiometricCredentials(clerk: Clerk): UseBiometricCredentialsReturn { + function featureUnavailableReason(): BiometricCredentialUnavailableReason | null { + const environment = (clerk as { __internal_environment?: EnvironmentResource | null }).__internal_environment; + const nativeSettings = environment?.authConfig?.nativeSettings; + if (!nativeSettings) { + return 'environment_unavailable'; + } + if (!nativeSettings.apiEnabled) { + return 'native_api_disabled'; + } + if (!nativeSettings.trustedDeviceSignInEnabled) { + return 'feature_disabled'; + } + return null; + } + + function requireUser(fallbackCode: OperationErrorCode): UserResource { + const user = clerk.user; + if (!user) { + throw biometricCredentialError(fallbackCode, 'Biometric credential management requires a signed-in user.'); + } + return user; + } + + async function deleteLocalCredential(biometrics: Biometrics, record: ExpoBiometricsRecord): Promise { + await callModule(biometrics, module => module.deleteRecord(record.localKeyId)); + } + + async function ignoreErrors(operation: () => Promise): Promise { + try { + await operation(); + } catch { + // Cleanup must not mask the error that triggered it. + } + } + + async function hasKey(biometrics: Biometrics, record: ExpoBiometricsRecord): Promise { + try { + return await biometrics.module.hasKey(record.localKeyId); + } catch (error) { + if (moduleErrorCode(error) === 'key_not_found') { + return false; + } + throw toBiometricCredentialError(error, biometrics.fallbackCode); + } + } + + async function localCredentialCandidates( + biometrics: Biometrics, + id: string | undefined, + identifierHint: string | undefined, + ): Promise { + const unavailableReason = featureUnavailableReason(); + if (unavailableReason) { + return unavailableReason; + } + + const matchesIdentifierHint = await identifierHintMatcher(biometrics, identifierHint); + const records = (await callModule(biometrics, module => module.listRecords())) + .filter( + record => + record.appIdentifier === biometrics.appIdentifier && + (id === undefined || record.id === id) && + matchesIdentifierHint(record), + ) + .sort(newestFirst); + if (records.length === 0) { + return 'no_local_credential'; + } + + const recordsWithKeys: ExpoBiometricsRecord[] = []; + for (const record of records) { + if (await hasKey(biometrics, record)) { + recordsWithKeys.push(record); + } else { + await deleteLocalCredential(biometrics, record); + } + } + if (recordsWithKeys.length === 0) { + return 'local_key_missing'; + } + + const device = await callModule(biometrics, module => module.getAvailability()); + const supportedRecords = recordsWithKeys.filter(record => + record.policy === 'biometry_or_device_passcode' ? device.canEvaluateDeviceOwner : device.canEvaluateBiometrics, + ); + if (supportedRecords.length === 0) { + return 'biometric_authentication_unavailable'; + } + return supportedRecords; + } + + async function selectLocalCredential( + biometrics: Biometrics, + id: string | undefined, + identifierHint: string | undefined, + ): Promise { + const candidates = await localCredentialCandidates(biometrics, id, identifierHint); + if (typeof candidates === 'string') { + return { unavailableReason: candidates }; + } + + const session = clerk.session; + const activeUser = session?.status === 'active' ? session.user : null; + if (!activeUser?.id) { + return { record: candidates[0] }; + } + + const activeUserRecords = candidates.filter(record => record.userId === activeUser.id); + if (activeUserRecords.length === 0) { + return { unavailableReason: 'no_local_credential' }; + } + + const serverCredentials = await activeUser.__experimental_getBiometricCredentials(); + let firstUnavailableReason: BiometricCredentialUnavailableReason | null = null; + for (const record of activeUserRecords) { + const serverCredential = serverCredentials.find(credential => credential.id === record.id); + if (!serverCredential) { + await deleteLocalCredential(biometrics, record); + firstUnavailableReason ??= 'server_credential_missing'; + continue; + } + if (serverCredential.status !== 'active') { + await deleteLocalCredential(biometrics, record); + firstUnavailableReason ??= 'server_credential_revoked'; + continue; + } + return { record }; + } + return { unavailableReason: firstUnavailableReason ?? 'server_credential_missing' }; + } + + return { + getAvailability: async params => { + const biometrics = requireBiometrics('E_TRUSTED_DEVICE_AVAILABILITY_FAILED'); + const selection = await selectLocalCredential(biometrics, params?.id, params?.identifierHint); + return selection.record + ? { isAvailable: true, unavailableReason: null } + : { isAvailable: false, unavailableReason: selection.unavailableReason }; + }, + + list: async () => { + requireBiometrics('E_TRUSTED_DEVICE_LIST_FAILED'); + const credentials = await requireUser('E_TRUSTED_DEVICE_LIST_FAILED').__experimental_getBiometricCredentials(); + return credentials.map(toBiometricCredential); + }, + + enroll: async params => { + const biometrics = requireBiometrics('E_TRUSTED_DEVICE_ENROLLMENT_FAILED'); + const session = clerk.session; + if (!session || (session.status !== 'active' && session.status !== 'pending') || !session.user) { + throw biometricCredentialError( + biometrics.fallbackCode, + 'Unable to enroll a biometric credential without an active or pending Clerk session.', + ); + } + const user = session.user; + const unavailableReason = featureUnavailableReason(); + if (unavailableReason) { + throw biometricCredentialError( + unavailableReason === 'environment_unavailable' ? unavailableReason : biometrics.fallbackCode, + `Unable to enroll a biometric credential: ${unavailableReason}.`, + ); + } + + const policy = params?.policy ?? DEFAULT_POLICY; + const key = await callModule(biometrics, module => module.createKey(policy)); + const enrollment: PrepareBiometricCredentialParams = { + platform: Platform.OS === 'android' ? 'android' : 'ios', + appIdentifier: biometrics.appIdentifier, + algorithm: 'ES256', + publicKeyJwk: key.publicKeyJwk, + ...(params?.name === undefined ? {} : { name: params.name }), + }; + + let credential: BiometricCredentialResource; + try { + const challenge = await user.__experimental_prepareBiometricCredential(enrollment); + const signature = await callModule(biometrics, module => + module.sign(key.localKeyId, challenge.clientData, params?.reason ?? DEFAULT_ENROLLMENT_REASON), + ); + credential = await user.__experimental_attemptBiometricCredential({ + ...enrollment, + clientData: challenge.clientData, + signature, + }); + } catch (error) { + await ignoreErrors(() => biometrics.module.deleteKey(key.localKeyId)); + throw error; + } + + const identifierHint = normalizeIdentifierHint(params?.identifierHint); + try { + await callModule(biometrics, module => + module.saveRecord( + { + id: credential.id, + localKeyId: key.localKeyId, + userId: user.id, + appIdentifier: biometrics.appIdentifier, + ...(identifierHint ? { identifierHint } : {}), + policy, + createdAt: credential.createdAt.getTime(), + updatedAt: credential.updatedAt.getTime(), + }, + { removeOtherRecordsForApp: true }, + ), + ); + } catch (error) { + await ignoreErrors(() => user.__experimental_revokeBiometricCredential(credential.id)); + await ignoreErrors(() => biometrics.module.deleteKey(key.localKeyId)); + throw error; + } + return toBiometricCredential(credential); + }, + + revoke: async id => { + const biometrics = requireBiometrics('E_TRUSTED_DEVICE_REVOCATION_FAILED'); + const credential = await requireUser(biometrics.fallbackCode).__experimental_revokeBiometricCredential(id); + await ignoreErrors(async () => { + const records = await biometrics.module.listRecords(); + const localKeyIds = new Set(records.filter(record => record.id === id).map(record => record.localKeyId)); + for (const localKeyId of localKeyIds) { + await biometrics.module.deleteRecord(localKeyId); + } + }); + return toBiometricCredential(credential); + }, + + signIn: async params => { + const biometrics = requireBiometrics('E_TRUSTED_DEVICE_SIGN_IN_FAILED'); + const clientSignIn = clerk.client?.signIn; + if (!clientSignIn) { + throw biometricCredentialError( + biometrics.fallbackCode, + 'Biometric sign-in requires a loaded Clerk client: the client sign-in resource is unavailable.', + ); + } + + const selection = await selectLocalCredential(biometrics, params?.id, params?.identifierHint); + if (!selection.record) { + throw biometricCredentialError( + biometrics.fallbackCode, + `Biometric sign-in is unavailable: ${selection.unavailableReason}.`, + ); + } + const record = selection.record; + + const forgetLocalCredentialIfMissing = async (error: unknown) => { + if (isMissingCredentialError(error)) { + await ignoreErrors(() => deleteLocalCredential(biometrics, record)); + } + return error; + }; + + let signIn: SignInResource; + try { + signIn = await clientSignIn.create({ strategy: 'trusted_device', trustedDeviceId: record.id }); + } catch (error) { + throw await forgetLocalCredentialIfMissing(error); + } + + const challenge = signIn.firstFactorVerification?.trustedDeviceChallenge; + if (!challenge) { + throw biometricCredentialError(biometrics.fallbackCode, 'Biometric sign-in did not return a challenge.'); + } + + let signature: string; + try { + signature = await biometrics.module.sign( + record.localKeyId, + challenge.clientData, + params?.reason ?? DEFAULT_SIGN_IN_REASON, + ); + } catch (error) { + const biometricError = toBiometricCredentialError(error, biometrics.fallbackCode); + if (biometricError.code === 'key_invalidated' || biometricError.code === 'key_not_found') { + await ignoreErrors(() => deleteLocalCredential(biometrics, record)); + } + throw biometricError; + } + + try { + signIn = await signIn.attemptFirstFactor({ + strategy: 'trusted_device', + trustedDeviceId: record.id, + clientData: challenge.clientData, + signature, + algorithm: 'ES256', + }); + } catch (error) { + throw await forgetLocalCredentialIfMissing(error); + } + + if (!signIn.status) { + throw biometricCredentialError(biometrics.fallbackCode, 'Biometric sign-in did not return a status.'); + } + return { + status: signIn.status, + createdSessionId: signIn.createdSessionId, + signIn, + setActive: clerk.setActive, + }; + }, + + reverify: createNativeReverify(clerk), + }; +} diff --git a/packages/expo/src/biometric-credentials/loadExpoBiometrics.ts b/packages/expo/src/biometric-credentials/loadExpoBiometrics.ts new file mode 100644 index 00000000000..b8da0aeb23d --- /dev/null +++ b/packages/expo/src/biometric-credentials/loadExpoBiometrics.ts @@ -0,0 +1,20 @@ +import type * as ExpoBiometrics from '@clerk/expo-biometrics'; + +export type ExpoBiometricsRecord = ExpoBiometrics.StoredBiometricCredentialRecord & { + identifierHintSha256?: string | null; +}; + +export type ExpoBiometricsModule = Omit & { + listRecords(): Promise; + hashIdentifierHint?: (identifierHint: string) => string | null; +}; + +export function loadExpoBiometrics(): ExpoBiometricsModule | null { + try { + // Synchronous require() in try/catch so Metro treats @clerk/expo-biometrics as an optional dependency. + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('@clerk/expo-biometrics') as ExpoBiometricsModule; + } catch { + return null; + } +} diff --git a/packages/expo/src/biometric-credentials/nativeReverification.ts b/packages/expo/src/biometric-credentials/nativeReverification.ts new file mode 100644 index 00000000000..53f911fc771 --- /dev/null +++ b/packages/expo/src/biometric-credentials/nativeReverification.ts @@ -0,0 +1,65 @@ +import type { useClerk } from '@clerk/react'; + +import { idle, pullFromNative } from '../provider/nativeClientSync'; +import { errorThrower } from '../utils/errors'; +import { ClerkExpoModule } from '../utils/native-module'; +import type { UseBiometricCredentialsReturn } from './types'; + +const CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS = + 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, then rebuild your development build.'; + +export function createNativeReverify(clerk: ReturnType): UseBiometricCredentialsReturn['reverify'] { + return async params => { + const nativeModule = ClerkExpoModule; + if (!nativeModule) { + return errorThrower.throw( + `Biometric reverification requires the @clerk/expo-native-components package in a development build. ${CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS}`, + ); + } + if (typeof nativeModule.reverifyWithBiometrics !== 'function') { + return errorThrower.throw( + 'Biometric reverification requires a development build containing a compatible version of @clerk/expo-native-components.', + ); + } + const level = params?.level ?? 'first_factor'; + if (level !== 'first_factor' && level !== 'second_factor' && level !== 'multi_factor') { + return errorThrower.throw('Biometric reverification level must be first_factor, second_factor, or multi_factor.'); + } + const session = clerk.session; + if (!session) { + return errorThrower.throw('Biometric reverification requires an active session.'); + } + await idle(); + if (clerk.session?.id !== session.id) { + return errorThrower.throw('The active session changed before biometric reverification started.'); + } + const verification = await nativeModule.reverifyWithBiometrics(session.id, level, params?.reason ?? null); + if (verification.sessionId !== session.id) { + return errorThrower.throw('Biometric reverification returned a different session.'); + } + if (verification.status === 'complete') { + session.clearCache(); + } + await pullFromNative(); + const synchronizedSession = clerk.session; + if (synchronizedSession?.id !== session.id) { + return errorThrower.throw('The active session changed during biometric reverification.'); + } + if (verification.status === 'complete') { + synchronizedSession.clearCache(); + const token = await synchronizedSession.getToken({ skipCache: true }); + if (!token) { + return errorThrower.throw('Unable to refresh the session token after biometric reverification.'); + } + if (clerk.session?.id !== session.id) { + return errorThrower.throw('The active session changed during biometric reverification.'); + } + } + return { + id: verification.id, + status: verification.status, + level: verification.level, + session: synchronizedSession, + }; + }; +} diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts index 213011066bf..965c2654da3 100644 --- a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts +++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts @@ -1,196 +1,14 @@ import { useClerk } from '@clerk/react'; import { useMemo } from 'react'; -import { idle, pullFromNative } from '../provider/nativeClientSync'; -import type { NativeBiometricCredential, NativeBiometricCredentialModule } from '../specs/NativeClerkModule.types'; -import { errorThrower } from '../utils/errors'; -import { ClerkExpoModule } from '../utils/native-module'; -import type { - BiometricCredential, - BiometricCredentialPlatform, - BiometricCredentialStatus, - UseBiometricCredentialsReturn, -} from './types'; - -const DEFAULT_POLICY = 'biometry_current_set'; - -const CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS = - 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, then rebuild your development build.'; - -function toBiometricCredentialPlatform(platform: string): BiometricCredentialPlatform { - return platform === 'ios' || platform === 'android' ? platform : 'unknown'; -} - -function toBiometricCredentialStatus(status: string): BiometricCredentialStatus { - return status === 'active' || status === 'revoked' ? status : 'unknown'; -} - -function getNativeModule(): NativeBiometricCredentialModule { - const nativeModule = ClerkExpoModule; - - if (!nativeModule) { - return errorThrower.throw( - `Biometric credentials require the @clerk/expo-native-components package in a development build. ${CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS}`, - ); - } - - if ( - !nativeModule.getTrustedDeviceAvailability || - !nativeModule.listTrustedDevices || - !nativeModule.enrollTrustedDevice || - !nativeModule.revokeTrustedDevice || - !nativeModule.signInWithTrustedDevice - ) { - return errorThrower.throw( - 'Biometric credentials require a development build containing a compatible version of @clerk/expo-native-components.', - ); - } - - return nativeModule as NativeBiometricCredentialModule; -} - -function toBiometricCredential(credential: NativeBiometricCredential): BiometricCredential { - return { - ...credential, - platform: toBiometricCredentialPlatform(credential.platform), - status: toBiometricCredentialStatus(credential.status), - createdAt: new Date(credential.createdAt), - updatedAt: new Date(credential.updatedAt), - lastUsedAt: credential.lastUsedAt == null ? null : new Date(credential.lastUsedAt), - revokedAt: credential.revokedAt == null ? null : new Date(credential.revokedAt), - }; -} - -function createBiometricCredentials(clerk: ReturnType): UseBiometricCredentialsReturn { - return { - getAvailability: async params => { - const nativeModule = getNativeModule(); - await idle(); - return nativeModule.getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null); - }, - list: async () => { - const nativeModule = getNativeModule(); - await idle(); - const credentials = await nativeModule.listTrustedDevices(); - return credentials.map(toBiometricCredential); - }, - enroll: async params => { - const nativeModule = getNativeModule(); - await idle(); - const credential = await nativeModule.enrollTrustedDevice( - params?.name ?? null, - params?.identifierHint ?? null, - params?.reason ?? null, - params?.policy ?? DEFAULT_POLICY, - ); - return toBiometricCredential(credential); - }, - revoke: async id => { - const nativeModule = getNativeModule(); - await idle(); - const credential = await nativeModule.revokeTrustedDevice(id); - return toBiometricCredential(credential); - }, - reverify: async params => { - const nativeModule = getNativeModule(); - if (typeof nativeModule.reverifyWithBiometrics !== 'function') { - return errorThrower.throw( - 'Biometric reverification requires a development build containing a compatible version of @clerk/expo-native-components.', - ); - } - const level = params?.level ?? 'first_factor'; - if (level !== 'first_factor' && level !== 'second_factor' && level !== 'multi_factor') { - return errorThrower.throw( - 'Biometric reverification level must be first_factor, second_factor, or multi_factor.', - ); - } - const session = clerk.session; - if (!session) { - return errorThrower.throw('Biometric reverification requires an active session.'); - } - await idle(); - if (clerk.session?.id !== session.id) { - return errorThrower.throw('The active session changed before biometric reverification started.'); - } - const verification = await nativeModule.reverifyWithBiometrics(session.id, level, params?.reason ?? null); - if (verification.sessionId !== session.id) { - return errorThrower.throw('Biometric reverification returned a different session.'); - } - if (verification.status === 'complete') { - session.clearCache(); - } - await pullFromNative(); - const synchronizedSession = clerk.session; - if (synchronizedSession?.id !== session.id) { - return errorThrower.throw('The active session changed during biometric reverification.'); - } - if (verification.status === 'complete') { - synchronizedSession.clearCache(); - const token = await synchronizedSession.getToken({ skipCache: true }); - if (!token) { - return errorThrower.throw('Unable to refresh the session token after biometric reverification.'); - } - if (clerk.session?.id !== session.id) { - return errorThrower.throw('The active session changed during biometric reverification.'); - } - } - return { - id: verification.id, - status: verification.status, - level: verification.level, - session: synchronizedSession, - }; - }, - signIn: async params => { - const nativeModule = getNativeModule(); - await idle(); - const nativeSignIn = await nativeModule.signInWithTrustedDevice( - params?.id ?? null, - params?.identifierHint ?? null, - params?.reason ?? null, - ); - await pullFromNative(); - - const client = clerk.client; - const signIn = client?.signIn; - if (!client || !signIn) { - return errorThrower.throw( - 'Unable to synchronize biometric sign-in with the Clerk JS client: the client sign-in resource is unavailable.', - ); - } - - const isComplete = nativeSignIn.status === 'complete'; - if (isComplete) { - if ( - !nativeSignIn.createdSessionId || - !client.signedInSessions.some(session => session.id === nativeSignIn.createdSessionId) - ) { - return errorThrower.throw( - 'Unable to synchronize biometric sign-in with the Clerk JS client: the created session is missing.', - ); - } - } else if (!signIn.id || signIn.id !== nativeSignIn.id) { - return errorThrower.throw( - 'Unable to synchronize biometric sign-in with the Clerk JS client: the sign-in attempt does not match.', - ); - } - - return { - status: isComplete ? nativeSignIn.status : (signIn.status ?? nativeSignIn.status), - createdSessionId: isComplete - ? nativeSignIn.createdSessionId - : (signIn.createdSessionId ?? nativeSignIn.createdSessionId), - signIn, - setActive: clerk.setActive, - }; - }, - }; -} +import { createBiometricCredentials } from './createBiometricCredentials'; +import type { UseBiometricCredentialsReturn } from './types'; /** * Accesses biometric credential enrollment, sign-in, and session reverification on iOS and Android. * - * The private key and biometric prompt are managed by Clerk's native SDK. + * Enrollment and sign-in require the `@clerk/expo-biometrics` package, which keeps the private key on the device. + * Session reverification requires the `@clerk/expo-native-components` package. */ export function useBiometricCredentials(): UseBiometricCredentialsReturn { const clerk = useClerk(); diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts index fb99d305e33..2d6af33f9f4 100644 --- a/packages/expo/src/specs/NativeClerkModule.types.ts +++ b/packages/expo/src/specs/NativeClerkModule.types.ts @@ -1,6 +1,4 @@ -import type { SessionVerificationLevel, SessionVerificationStatus, SignInStatus } from '@clerk/shared/types'; - -import type { BiometricCredentialAvailability, BiometricCredentialPolicy } from '../biometric-credentials/types'; +import type { SessionVerificationLevel, SessionVerificationStatus } from '@clerk/shared/types'; export type NativeAuthFlowState = { isLoaded: boolean; @@ -22,26 +20,6 @@ export type NativeClientSyncModule = { refreshClient(): Promise; }; -export type NativeBiometricCredential = { - id: string; - object: 'trusted_device'; - platform: string; - appIdentifier: string; - name: string | null; - algorithm: 'ES256' | (string & {}); - status: string; - createdAt: number; - updatedAt: number; - lastUsedAt: number | null; - revokedAt: number | null; -}; - -export type NativeBiometricSignInResult = { - id: string; - status: SignInStatus | (string & {}); - createdSessionId: string | null; -}; - export type NativeBiometricReverificationResult = { id: string | null; status: SessionVerificationStatus | (string & {}); @@ -50,23 +28,6 @@ export type NativeBiometricReverificationResult = { }; export type NativeBiometricCredentialModule = { - getTrustedDeviceAvailability( - id: string | null, - identifierHint: string | null, - ): Promise; - listTrustedDevices(): Promise; - enrollTrustedDevice( - deviceName: string | null, - identifierHint: string | null, - reason: string | null, - policy: BiometricCredentialPolicy, - ): Promise; - revokeTrustedDevice(id: string): Promise; - signInWithTrustedDevice( - id: string | null, - identifierHint: string | null, - reason: string | null, - ): Promise; reverifyWithBiometrics( sessionId: string, level: SessionVerificationLevel, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3084ae1dec..f10d8bb4b9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -659,6 +659,9 @@ importers: specifier: catalog:repo version: 2.8.1 devDependencies: + '@clerk/expo-biometrics': + specifier: workspace:* + version: link:../expo-biometrics '@clerk/expo-google-signin': specifier: workspace:* version: link:../expo-google-signin From d59dd78c0090749aaca9574e2ad460c87e8a8330 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:34:31 -0400 Subject: [PATCH 2/3] fix(expo): keep API error codes on biometric credential errors Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/expo-biometric-credentials-js.md | 1 - .../__tests__/useBiometricCredentials.test.ts | 79 +++++++++++++++++-- .../createBiometricCredentials.ts | 55 ++++++++++--- 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/.changeset/expo-biometric-credentials-js.md b/.changeset/expo-biometric-credentials-js.md index e0ec78666d4..e9c3d9a89f3 100644 --- a/.changeset/expo-biometric-credentials-js.md +++ b/.changeset/expo-biometric-credentials-js.md @@ -7,5 +7,4 @@ - Install `@clerk/expo-biometrics` (`npx expo install @clerk/expo-biometrics`) and rebuild your development build. Without it, these methods throw an error explaining how to install it. - Enrollment, listing, revocation and sign-in no longer require `@clerk/expo-native-components` or iOS 17. - `reverify()` still uses Clerk's native SDK and requires `@clerk/expo-native-components`. -- Errors returned by Clerk's API are now thrown as `ClerkAPIResponseError`, like the rest of `@clerk/expo`. Read the API error code from `error.errors[0].code`. Errors raised on the device, such as `biometric_authentication_canceled` or `key_invalidated`, keep their `code`. - On Android, credentials enrolled through the previous native implementation may not be found by apps that don't use `@clerk/expo-native-components`. Users of those apps need to enroll again. diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts index d2805f54e1c..33bd50960e1 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -114,11 +114,29 @@ function serverCredential(overrides: Record = {}) { function apiError(code: string, paramName?: string) { return new ClerkAPIResponseError(code, { - data: [{ code, message: code, long_message: code, meta: paramName ? { param_name: paramName } : {} }], + data: [ + { + code, + message: `${code} message`, + long_message: `${code} long message`, + meta: paramName ? { param_name: paramName } : {}, + }, + ], status: 422, }); } +async function expectBiometricApiError(operation: Promise, cause: ClerkAPIResponseError) { + const error = await operation.then( + () => expect.unreachable('Expected the operation to reject'), + (rejection: unknown) => rejection, + ); + const code = cause.errors[0].code; + expect(isBiometricCredentialError(error)).toBe(true); + expect(error).not.toBeInstanceOf(ClerkAPIResponseError); + expect(error).toMatchObject({ code, message: `${code} long message`, cause }); +} + function createClerk() { const user = { id: 'user_1', @@ -386,7 +404,7 @@ describe('getAvailability', () => { const error = apiError('session_reverification_required'); clerk.user.__experimental_getBiometricCredentials.mockRejectedValue(error); - await expect(renderBiometricCredentials().getAvailability()).rejects.toBe(error); + await expectBiometricApiError(renderBiometricCredentials().getAvailability(), error); expect(biometrics.store.records).toHaveLength(1); }); }); @@ -567,7 +585,7 @@ describe('enroll', () => { const error = apiError('session_reverification_required'); clerk.user.__experimental_prepareBiometricCredential.mockRejectedValue(error); - await expect(renderBiometricCredentials().enroll()).rejects.toBe(error); + await expectBiometricApiError(renderBiometricCredentials().enroll(), error); expect(biometrics.deleteKey).toHaveBeenCalledWith('key_new'); expect(biometrics.sign).not.toHaveBeenCalled(); expect(biometrics.saveRecord).not.toHaveBeenCalled(); @@ -587,7 +605,7 @@ describe('enroll', () => { const error = apiError('form_param_invalid', 'signature'); clerk.user.__experimental_attemptBiometricCredential.mockRejectedValue(error); - await expect(renderBiometricCredentials().enroll()).rejects.toBe(error); + await expectBiometricApiError(renderBiometricCredentials().enroll(), error); expect(biometrics.deleteKey).toHaveBeenCalledWith('key_new'); expect(biometrics.saveRecord).not.toHaveBeenCalled(); expect(clerk.user.__experimental_revokeBiometricCredential).not.toHaveBeenCalled(); @@ -637,7 +655,7 @@ describe('revoke', () => { const error = apiError('session_reverification_required'); clerk.user.__experimental_revokeBiometricCredential.mockRejectedValue(error); - await expect(renderBiometricCredentials().revoke('td_1')).rejects.toBe(error); + await expectBiometricApiError(renderBiometricCredentials().revoke('td_1'), error); expect(biometrics.deleteRecord).not.toHaveBeenCalled(); }); @@ -744,7 +762,7 @@ describe('signIn', () => { const error = apiError(code, 'trusted_device_id'); clerk.clientSignIn.create.mockRejectedValue(error); - await expect(renderBiometricCredentials().signIn()).rejects.toBe(error); + await expectBiometricApiError(renderBiometricCredentials().signIn(), error); expect(biometrics.deleteRecord).toHaveBeenCalledWith('key_1'); expect(biometrics.store.records).toEqual([]); }, @@ -755,7 +773,7 @@ describe('signIn', () => { const error = apiError('trusted_device_not_registered', 'trusted_device_id'); clerk.createdSignIn.attemptFirstFactor.mockRejectedValue(error); - await expect(renderBiometricCredentials().signIn()).rejects.toBe(error); + await expectBiometricApiError(renderBiometricCredentials().signIn(), error); expect(biometrics.store.records).toEqual([]); }); @@ -764,7 +782,7 @@ describe('signIn', () => { const error = apiError('form_resource_not_found', 'sign_in_id'); clerk.createdSignIn.attemptFirstFactor.mockRejectedValue(error); - await expect(renderBiometricCredentials().signIn()).rejects.toBe(error); + await expectBiometricApiError(renderBiometricCredentials().signIn(), error); expect(biometrics.deleteRecord).not.toHaveBeenCalled(); }); @@ -806,6 +824,51 @@ describe('signIn', () => { }); }); +describe('API errors', () => { + test('surface a reverification-required error with its API code', async () => { + signInClerkUser(); + clerk.user.__experimental_prepareBiometricCredential.mockRejectedValue(apiError('session_reverification_required')); + + const operation = renderBiometricCredentials().enroll(); + + await expect(operation).rejects.toMatchObject({ code: 'session_reverification_required' }); + await operation.catch(error => expect(isBiometricCredentialError(error)).toBe(true)); + }); + + test('surface a trusted_device_not_registered error with its API code', async () => { + addLocalCredential(); + clerk.clientSignIn.create.mockRejectedValue(apiError('trusted_device_not_registered', 'trusted_device_id')); + + const operation = renderBiometricCredentials().signIn(); + + await expect(operation).rejects.toMatchObject({ + code: 'trusted_device_not_registered', + message: 'trusted_device_not_registered long message', + }); + await operation.catch(error => expect(isBiometricCredentialError(error)).toBe(true)); + }); + + test('fall back to the short message and the operation code', async () => { + signInClerkUser(); + const withoutLongMessage = new ClerkAPIResponseError('native_api_disabled', { + data: [{ code: 'native_api_disabled', message: 'Native API is disabled', long_message: undefined as never }], + status: 403, + }); + clerk.user.__experimental_getBiometricCredentials.mockRejectedValueOnce(withoutLongMessage); + clerk.user.__experimental_getBiometricCredentials.mockRejectedValueOnce(new Error('Network down')); + const biometricCredentials = renderBiometricCredentials(); + + await expect(biometricCredentials.list()).rejects.toMatchObject({ + code: 'native_api_disabled', + message: 'Native API is disabled', + }); + await expect(biometricCredentials.list()).rejects.toMatchObject({ + code: 'E_TRUSTED_DEVICE_LIST_FAILED', + message: 'Network down', + }); + }); +}); + describe('missing @clerk/expo-biometrics', () => { test.each(['getAvailability', 'list', 'enroll', 'revoke', 'signIn'] as const)( '%s explains how to install the package', diff --git a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts index 7210f735e0a..b89f37af8d6 100644 --- a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts +++ b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts @@ -173,6 +173,29 @@ function toBiometricCredential(credential: BiometricCredentialResource): Biometr }; } +function toApiBiometricCredentialError(error: unknown, fallbackCode: OperationErrorCode): unknown { + if (isClerkAPIResponseError(error)) { + const apiError = error.errors[0]; + return biometricCredentialError( + apiError?.code ?? fallbackCode, + apiError?.longMessage ?? apiError?.message ?? error.message, + error, + ); + } + if (error instanceof Error && typeof (error as { code?: unknown }).code === 'string') { + return error; + } + return biometricCredentialError(fallbackCode, error instanceof Error ? error.message : String(error), error); +} + +async function callApi(fallbackCode: OperationErrorCode, request: () => Promise): Promise { + try { + return await request(); + } catch (error) { + throw toApiBiometricCredentialError(error, fallbackCode); + } +} + function isMissingCredentialError(error: unknown): boolean { return ( isClerkAPIResponseError(error) && @@ -296,7 +319,9 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential return { unavailableReason: 'no_local_credential' }; } - const serverCredentials = await activeUser.__experimental_getBiometricCredentials(); + const serverCredentials = await callApi(biometrics.fallbackCode, () => + activeUser.__experimental_getBiometricCredentials(), + ); let firstUnavailableReason: BiometricCredentialUnavailableReason | null = null; for (const record of activeUserRecords) { const serverCredential = serverCredentials.find(credential => credential.id === record.id); @@ -325,8 +350,9 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential }, list: async () => { - requireBiometrics('E_TRUSTED_DEVICE_LIST_FAILED'); - const credentials = await requireUser('E_TRUSTED_DEVICE_LIST_FAILED').__experimental_getBiometricCredentials(); + const biometrics = requireBiometrics('E_TRUSTED_DEVICE_LIST_FAILED'); + const user = requireUser(biometrics.fallbackCode); + const credentials = await callApi(biometrics.fallbackCode, () => user.__experimental_getBiometricCredentials()); return credentials.map(toBiometricCredential); }, @@ -360,15 +386,19 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential let credential: BiometricCredentialResource; try { - const challenge = await user.__experimental_prepareBiometricCredential(enrollment); + const challenge = await callApi(biometrics.fallbackCode, () => + user.__experimental_prepareBiometricCredential(enrollment), + ); const signature = await callModule(biometrics, module => module.sign(key.localKeyId, challenge.clientData, params?.reason ?? DEFAULT_ENROLLMENT_REASON), ); - credential = await user.__experimental_attemptBiometricCredential({ - ...enrollment, - clientData: challenge.clientData, - signature, - }); + credential = await callApi(biometrics.fallbackCode, () => + user.__experimental_attemptBiometricCredential({ + ...enrollment, + clientData: challenge.clientData, + signature, + }), + ); } catch (error) { await ignoreErrors(() => biometrics.module.deleteKey(key.localKeyId)); throw error; @@ -401,7 +431,10 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential revoke: async id => { const biometrics = requireBiometrics('E_TRUSTED_DEVICE_REVOCATION_FAILED'); - const credential = await requireUser(biometrics.fallbackCode).__experimental_revokeBiometricCredential(id); + const user = requireUser(biometrics.fallbackCode); + const credential = await callApi(biometrics.fallbackCode, () => + user.__experimental_revokeBiometricCredential(id), + ); await ignoreErrors(async () => { const records = await biometrics.module.listRecords(); const localKeyIds = new Set(records.filter(record => record.id === id).map(record => record.localKeyId)); @@ -435,7 +468,7 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential if (isMissingCredentialError(error)) { await ignoreErrors(() => deleteLocalCredential(biometrics, record)); } - return error; + return toApiBiometricCredentialError(error, biometrics.fallbackCode); }; let signIn: SignInResource; From 997b6d9ed267d907075a960d5e92302f2bc61c0b Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:05:04 -0400 Subject: [PATCH 3/3] fix(expo): treat missing secure key storage as unavailable getAvailability() and signIn() report biometric_authentication_unavailable before reading local records when @clerk/expo-biometrics reports no secure key storage, and enroll() maps its secure_key_storage_unavailable rejection to biometric_authentication_unavailable before contacting Clerk. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/useBiometricCredentials.test.ts | 66 +++++++++++++++++++ .../createBiometricCredentials.ts | 8 ++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts index 33bd50960e1..795dd601c70 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -58,6 +58,7 @@ function createExpoBiometrics({ withHash = true }: { withHash?: boolean } = {}) canEvaluateBiometrics: true, canEvaluateDeviceOwner: true, errorCode: null, + secureKeyStorageAvailable: true as boolean | undefined, })), createKey: asyncFn(() => { store.keys.add('key_new'); @@ -220,6 +221,42 @@ describe('getAvailability', () => { expect(biometrics.listRecords).not.toHaveBeenCalled(); }); + test('reports biometric authentication unavailable without secure key storage, before reading local records', async () => { + addLocalCredential(); + biometrics.getAvailability.mockResolvedValue({ + biometryType: 'faceID', + canEvaluateBiometrics: true, + canEvaluateDeviceOwner: true, + errorCode: null, + secureKeyStorageAvailable: false, + }); + + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: false, + unavailableReason: 'biometric_authentication_unavailable', + }); + expect(biometrics.listRecords).not.toHaveBeenCalled(); + expect(biometrics.hasKey).not.toHaveBeenCalled(); + expect(biometrics.deleteRecord).not.toHaveBeenCalled(); + expect(biometrics.store.records).toHaveLength(1); + }); + + test('treats a module that does not report secure key storage as having it', async () => { + addLocalCredential(); + biometrics.getAvailability.mockResolvedValue({ + biometryType: 'faceID', + canEvaluateBiometrics: true, + canEvaluateDeviceOwner: true, + errorCode: null, + secureKeyStorageAvailable: undefined, + }); + + await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({ + isAvailable: true, + unavailableReason: null, + }); + }); + test('reports no local credential when the device has none for this app', async () => { addLocalCredential({ appIdentifier: 'com.example.other' }); @@ -581,6 +618,18 @@ describe('enroll', () => { expect(clerk.user.__experimental_prepareBiometricCredential).not.toHaveBeenCalled(); }); + test('fails fast when the device has no secure key storage', async () => { + biometrics.createKey.mockRejectedValue(moduleError('secure_key_storage_unavailable', 'No Secure Enclave')); + + await expect(renderBiometricCredentials().enroll()).rejects.toMatchObject({ + code: 'biometric_authentication_unavailable', + message: 'No Secure Enclave', + }); + expect(clerk.user.__experimental_prepareBiometricCredential).not.toHaveBeenCalled(); + expect(biometrics.sign).not.toHaveBeenCalled(); + expect(biometrics.saveRecord).not.toHaveBeenCalled(); + }); + test('surfaces reverification errors from prepare unchanged and deletes the key', async () => { const error = apiError('session_reverification_required'); clerk.user.__experimental_prepareBiometricCredential.mockRejectedValue(error); @@ -740,6 +789,23 @@ describe('signIn', () => { expect(clerk.clientSignIn.create).not.toHaveBeenCalled(); }); + test('rejects without contacting the server when the device has no secure key storage', async () => { + addLocalCredential(); + biometrics.getAvailability.mockResolvedValue({ + biometryType: 'faceID', + canEvaluateBiometrics: true, + canEvaluateDeviceOwner: true, + errorCode: null, + secureKeyStorageAvailable: false, + }); + + const operation = renderBiometricCredentials().signIn(); + + await expect(operation).rejects.toThrow('biometric_authentication_unavailable'); + expect(clerk.clientSignIn.create).not.toHaveBeenCalled(); + expect(biometrics.sign).not.toHaveBeenCalled(); + }); + test('rejects when the Clerk client is unavailable', async () => { addLocalCredential(); clerk.instance.client = undefined; diff --git a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts index b89f37af8d6..a7e5d4c8c13 100644 --- a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts +++ b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts @@ -61,6 +61,7 @@ const MODULE_ERROR_CODES: Record module.getAvailability()); + // Older @clerk/expo-biometrics versions do not report secureKeyStorageAvailable. + if (device.secureKeyStorageAvailable === false) { + return 'biometric_authentication_unavailable'; + } + const matchesIdentifierHint = await identifierHintMatcher(biometrics, identifierHint); const records = (await callModule(biometrics, module => module.listRecords())) .filter( @@ -288,7 +295,6 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential return 'local_key_missing'; } - const device = await callModule(biometrics, module => module.getAvailability()); const supportedRecords = recordsWithKeys.filter(record => record.policy === 'biometry_or_device_passcode' ? device.canEvaluateDeviceOwner : device.canEvaluateBiometrics, );