Skip to content

Commit 997b6d9

Browse files
mikepitreclaude
andcommitted
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) <noreply@anthropic.com>
1 parent d59dd78 commit 997b6d9

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

‎packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ function createExpoBiometrics({ withHash = true }: { withHash?: boolean } = {})
5858
canEvaluateBiometrics: true,
5959
canEvaluateDeviceOwner: true,
6060
errorCode: null,
61+
secureKeyStorageAvailable: true as boolean | undefined,
6162
})),
6263
createKey: asyncFn(() => {
6364
store.keys.add('key_new');
@@ -220,6 +221,42 @@ describe('getAvailability', () => {
220221
expect(biometrics.listRecords).not.toHaveBeenCalled();
221222
});
222223

224+
test('reports biometric authentication unavailable without secure key storage, before reading local records', async () => {
225+
addLocalCredential();
226+
biometrics.getAvailability.mockResolvedValue({
227+
biometryType: 'faceID',
228+
canEvaluateBiometrics: true,
229+
canEvaluateDeviceOwner: true,
230+
errorCode: null,
231+
secureKeyStorageAvailable: false,
232+
});
233+
234+
await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({
235+
isAvailable: false,
236+
unavailableReason: 'biometric_authentication_unavailable',
237+
});
238+
expect(biometrics.listRecords).not.toHaveBeenCalled();
239+
expect(biometrics.hasKey).not.toHaveBeenCalled();
240+
expect(biometrics.deleteRecord).not.toHaveBeenCalled();
241+
expect(biometrics.store.records).toHaveLength(1);
242+
});
243+
244+
test('treats a module that does not report secure key storage as having it', async () => {
245+
addLocalCredential();
246+
biometrics.getAvailability.mockResolvedValue({
247+
biometryType: 'faceID',
248+
canEvaluateBiometrics: true,
249+
canEvaluateDeviceOwner: true,
250+
errorCode: null,
251+
secureKeyStorageAvailable: undefined,
252+
});
253+
254+
await expect(renderBiometricCredentials().getAvailability()).resolves.toEqual({
255+
isAvailable: true,
256+
unavailableReason: null,
257+
});
258+
});
259+
223260
test('reports no local credential when the device has none for this app', async () => {
224261
addLocalCredential({ appIdentifier: 'com.example.other' });
225262

@@ -581,6 +618,18 @@ describe('enroll', () => {
581618
expect(clerk.user.__experimental_prepareBiometricCredential).not.toHaveBeenCalled();
582619
});
583620

621+
test('fails fast when the device has no secure key storage', async () => {
622+
biometrics.createKey.mockRejectedValue(moduleError('secure_key_storage_unavailable', 'No Secure Enclave'));
623+
624+
await expect(renderBiometricCredentials().enroll()).rejects.toMatchObject({
625+
code: 'biometric_authentication_unavailable',
626+
message: 'No Secure Enclave',
627+
});
628+
expect(clerk.user.__experimental_prepareBiometricCredential).not.toHaveBeenCalled();
629+
expect(biometrics.sign).not.toHaveBeenCalled();
630+
expect(biometrics.saveRecord).not.toHaveBeenCalled();
631+
});
632+
584633
test('surfaces reverification errors from prepare unchanged and deletes the key', async () => {
585634
const error = apiError('session_reverification_required');
586635
clerk.user.__experimental_prepareBiometricCredential.mockRejectedValue(error);
@@ -740,6 +789,23 @@ describe('signIn', () => {
740789
expect(clerk.clientSignIn.create).not.toHaveBeenCalled();
741790
});
742791

792+
test('rejects without contacting the server when the device has no secure key storage', async () => {
793+
addLocalCredential();
794+
biometrics.getAvailability.mockResolvedValue({
795+
biometryType: 'faceID',
796+
canEvaluateBiometrics: true,
797+
canEvaluateDeviceOwner: true,
798+
errorCode: null,
799+
secureKeyStorageAvailable: false,
800+
});
801+
802+
const operation = renderBiometricCredentials().signIn();
803+
804+
await expect(operation).rejects.toThrow('biometric_authentication_unavailable');
805+
expect(clerk.clientSignIn.create).not.toHaveBeenCalled();
806+
expect(biometrics.sign).not.toHaveBeenCalled();
807+
});
808+
743809
test('rejects when the Clerk client is unavailable', async () => {
744810
addLocalCredential();
745811
clerk.instance.client = undefined;

‎packages/expo/src/biometric-credentials/createBiometricCredentials.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const MODULE_ERROR_CODES: Record<string, BiometricCredentialErrorCode | undefine
6161
biometry_not_enrolled: 'biometric_authentication_unavailable',
6262
biometry_lockout: 'biometric_authentication_unavailable',
6363
passcode_not_set: 'biometric_authentication_unavailable',
64+
secure_key_storage_unavailable: 'biometric_authentication_unavailable',
6465
key_not_found: 'key_not_found',
6566
key_invalidated: 'key_invalidated',
6667
key_generation_failed: 'key_generation_failed',
@@ -263,6 +264,12 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential
263264
return unavailableReason;
264265
}
265266

267+
const device = await callModule(biometrics, module => module.getAvailability());
268+
// Older @clerk/expo-biometrics versions do not report secureKeyStorageAvailable.
269+
if (device.secureKeyStorageAvailable === false) {
270+
return 'biometric_authentication_unavailable';
271+
}
272+
266273
const matchesIdentifierHint = await identifierHintMatcher(biometrics, identifierHint);
267274
const records = (await callModule(biometrics, module => module.listRecords()))
268275
.filter(
@@ -288,7 +295,6 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential
288295
return 'local_key_missing';
289296
}
290297

291-
const device = await callModule(biometrics, module => module.getAvailability());
292298
const supportedRecords = recordsWithKeys.filter(record =>
293299
record.policy === 'biometry_or_device_passcode' ? device.canEvaluateDeviceOwner : device.canEvaluateBiometrics,
294300
);

0 commit comments

Comments
 (0)