Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/expo-biometric-credentials-js.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@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`.
- 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.
5 changes: 5 additions & 0 deletions packages/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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",
Expand All @@ -153,6 +155,9 @@
"react-native": ">=0.75"
},
"peerDependenciesMeta": {
"@clerk/expo-biometrics": {
"optional": true
},
"@clerk/expo-google-signin": {
"optional": true
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }));

Expand Down Expand Up @@ -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)(
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading