Skip to content

Commit d59dd78

Browse files
mikepitreclaude
andcommitted
fix(expo): keep API error codes on biometric credential errors
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3a33b51 commit d59dd78

3 files changed

Lines changed: 115 additions & 20 deletions

File tree

‎.changeset/expo-biometric-credentials-js.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,4 @@
77
- 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.
88
- Enrollment, listing, revocation and sign-in no longer require `@clerk/expo-native-components` or iOS 17.
99
- `reverify()` still uses Clerk's native SDK and requires `@clerk/expo-native-components`.
10-
- 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`.
1110
- 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.

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

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,29 @@ function serverCredential(overrides: Record<string, unknown> = {}) {
114114

115115
function apiError(code: string, paramName?: string) {
116116
return new ClerkAPIResponseError(code, {
117-
data: [{ code, message: code, long_message: code, meta: paramName ? { param_name: paramName } : {} }],
117+
data: [
118+
{
119+
code,
120+
message: `${code} message`,
121+
long_message: `${code} long message`,
122+
meta: paramName ? { param_name: paramName } : {},
123+
},
124+
],
118125
status: 422,
119126
});
120127
}
121128

129+
async function expectBiometricApiError(operation: Promise<unknown>, cause: ClerkAPIResponseError) {
130+
const error = await operation.then(
131+
() => expect.unreachable('Expected the operation to reject'),
132+
(rejection: unknown) => rejection,
133+
);
134+
const code = cause.errors[0].code;
135+
expect(isBiometricCredentialError(error)).toBe(true);
136+
expect(error).not.toBeInstanceOf(ClerkAPIResponseError);
137+
expect(error).toMatchObject({ code, message: `${code} long message`, cause });
138+
}
139+
122140
function createClerk() {
123141
const user = {
124142
id: 'user_1',
@@ -386,7 +404,7 @@ describe('getAvailability', () => {
386404
const error = apiError('session_reverification_required');
387405
clerk.user.__experimental_getBiometricCredentials.mockRejectedValue(error);
388406

389-
await expect(renderBiometricCredentials().getAvailability()).rejects.toBe(error);
407+
await expectBiometricApiError(renderBiometricCredentials().getAvailability(), error);
390408
expect(biometrics.store.records).toHaveLength(1);
391409
});
392410
});
@@ -567,7 +585,7 @@ describe('enroll', () => {
567585
const error = apiError('session_reverification_required');
568586
clerk.user.__experimental_prepareBiometricCredential.mockRejectedValue(error);
569587

570-
await expect(renderBiometricCredentials().enroll()).rejects.toBe(error);
588+
await expectBiometricApiError(renderBiometricCredentials().enroll(), error);
571589
expect(biometrics.deleteKey).toHaveBeenCalledWith('key_new');
572590
expect(biometrics.sign).not.toHaveBeenCalled();
573591
expect(biometrics.saveRecord).not.toHaveBeenCalled();
@@ -587,7 +605,7 @@ describe('enroll', () => {
587605
const error = apiError('form_param_invalid', 'signature');
588606
clerk.user.__experimental_attemptBiometricCredential.mockRejectedValue(error);
589607

590-
await expect(renderBiometricCredentials().enroll()).rejects.toBe(error);
608+
await expectBiometricApiError(renderBiometricCredentials().enroll(), error);
591609
expect(biometrics.deleteKey).toHaveBeenCalledWith('key_new');
592610
expect(biometrics.saveRecord).not.toHaveBeenCalled();
593611
expect(clerk.user.__experimental_revokeBiometricCredential).not.toHaveBeenCalled();
@@ -637,7 +655,7 @@ describe('revoke', () => {
637655
const error = apiError('session_reverification_required');
638656
clerk.user.__experimental_revokeBiometricCredential.mockRejectedValue(error);
639657

640-
await expect(renderBiometricCredentials().revoke('td_1')).rejects.toBe(error);
658+
await expectBiometricApiError(renderBiometricCredentials().revoke('td_1'), error);
641659
expect(biometrics.deleteRecord).not.toHaveBeenCalled();
642660
});
643661

@@ -744,7 +762,7 @@ describe('signIn', () => {
744762
const error = apiError(code, 'trusted_device_id');
745763
clerk.clientSignIn.create.mockRejectedValue(error);
746764

747-
await expect(renderBiometricCredentials().signIn()).rejects.toBe(error);
765+
await expectBiometricApiError(renderBiometricCredentials().signIn(), error);
748766
expect(biometrics.deleteRecord).toHaveBeenCalledWith('key_1');
749767
expect(biometrics.store.records).toEqual([]);
750768
},
@@ -755,7 +773,7 @@ describe('signIn', () => {
755773
const error = apiError('trusted_device_not_registered', 'trusted_device_id');
756774
clerk.createdSignIn.attemptFirstFactor.mockRejectedValue(error);
757775

758-
await expect(renderBiometricCredentials().signIn()).rejects.toBe(error);
776+
await expectBiometricApiError(renderBiometricCredentials().signIn(), error);
759777
expect(biometrics.store.records).toEqual([]);
760778
});
761779

@@ -764,7 +782,7 @@ describe('signIn', () => {
764782
const error = apiError('form_resource_not_found', 'sign_in_id');
765783
clerk.createdSignIn.attemptFirstFactor.mockRejectedValue(error);
766784

767-
await expect(renderBiometricCredentials().signIn()).rejects.toBe(error);
785+
await expectBiometricApiError(renderBiometricCredentials().signIn(), error);
768786
expect(biometrics.deleteRecord).not.toHaveBeenCalled();
769787
});
770788

@@ -806,6 +824,51 @@ describe('signIn', () => {
806824
});
807825
});
808826

827+
describe('API errors', () => {
828+
test('surface a reverification-required error with its API code', async () => {
829+
signInClerkUser();
830+
clerk.user.__experimental_prepareBiometricCredential.mockRejectedValue(apiError('session_reverification_required'));
831+
832+
const operation = renderBiometricCredentials().enroll();
833+
834+
await expect(operation).rejects.toMatchObject({ code: 'session_reverification_required' });
835+
await operation.catch(error => expect(isBiometricCredentialError(error)).toBe(true));
836+
});
837+
838+
test('surface a trusted_device_not_registered error with its API code', async () => {
839+
addLocalCredential();
840+
clerk.clientSignIn.create.mockRejectedValue(apiError('trusted_device_not_registered', 'trusted_device_id'));
841+
842+
const operation = renderBiometricCredentials().signIn();
843+
844+
await expect(operation).rejects.toMatchObject({
845+
code: 'trusted_device_not_registered',
846+
message: 'trusted_device_not_registered long message',
847+
});
848+
await operation.catch(error => expect(isBiometricCredentialError(error)).toBe(true));
849+
});
850+
851+
test('fall back to the short message and the operation code', async () => {
852+
signInClerkUser();
853+
const withoutLongMessage = new ClerkAPIResponseError('native_api_disabled', {
854+
data: [{ code: 'native_api_disabled', message: 'Native API is disabled', long_message: undefined as never }],
855+
status: 403,
856+
});
857+
clerk.user.__experimental_getBiometricCredentials.mockRejectedValueOnce(withoutLongMessage);
858+
clerk.user.__experimental_getBiometricCredentials.mockRejectedValueOnce(new Error('Network down'));
859+
const biometricCredentials = renderBiometricCredentials();
860+
861+
await expect(biometricCredentials.list()).rejects.toMatchObject({
862+
code: 'native_api_disabled',
863+
message: 'Native API is disabled',
864+
});
865+
await expect(biometricCredentials.list()).rejects.toMatchObject({
866+
code: 'E_TRUSTED_DEVICE_LIST_FAILED',
867+
message: 'Network down',
868+
});
869+
});
870+
});
871+
809872
describe('missing @clerk/expo-biometrics', () => {
810873
test.each(['getAvailability', 'list', 'enroll', 'revoke', 'signIn'] as const)(
811874
'%s explains how to install the package',

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

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,29 @@ function toBiometricCredential(credential: BiometricCredentialResource): Biometr
173173
};
174174
}
175175

176+
function toApiBiometricCredentialError(error: unknown, fallbackCode: OperationErrorCode): unknown {
177+
if (isClerkAPIResponseError(error)) {
178+
const apiError = error.errors[0];
179+
return biometricCredentialError(
180+
apiError?.code ?? fallbackCode,
181+
apiError?.longMessage ?? apiError?.message ?? error.message,
182+
error,
183+
);
184+
}
185+
if (error instanceof Error && typeof (error as { code?: unknown }).code === 'string') {
186+
return error;
187+
}
188+
return biometricCredentialError(fallbackCode, error instanceof Error ? error.message : String(error), error);
189+
}
190+
191+
async function callApi<T>(fallbackCode: OperationErrorCode, request: () => Promise<T>): Promise<T> {
192+
try {
193+
return await request();
194+
} catch (error) {
195+
throw toApiBiometricCredentialError(error, fallbackCode);
196+
}
197+
}
198+
176199
function isMissingCredentialError(error: unknown): boolean {
177200
return (
178201
isClerkAPIResponseError(error) &&
@@ -296,7 +319,9 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential
296319
return { unavailableReason: 'no_local_credential' };
297320
}
298321

299-
const serverCredentials = await activeUser.__experimental_getBiometricCredentials();
322+
const serverCredentials = await callApi(biometrics.fallbackCode, () =>
323+
activeUser.__experimental_getBiometricCredentials(),
324+
);
300325
let firstUnavailableReason: BiometricCredentialUnavailableReason | null = null;
301326
for (const record of activeUserRecords) {
302327
const serverCredential = serverCredentials.find(credential => credential.id === record.id);
@@ -325,8 +350,9 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential
325350
},
326351

327352
list: async () => {
328-
requireBiometrics('E_TRUSTED_DEVICE_LIST_FAILED');
329-
const credentials = await requireUser('E_TRUSTED_DEVICE_LIST_FAILED').__experimental_getBiometricCredentials();
353+
const biometrics = requireBiometrics('E_TRUSTED_DEVICE_LIST_FAILED');
354+
const user = requireUser(biometrics.fallbackCode);
355+
const credentials = await callApi(biometrics.fallbackCode, () => user.__experimental_getBiometricCredentials());
330356
return credentials.map(toBiometricCredential);
331357
},
332358

@@ -360,15 +386,19 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential
360386

361387
let credential: BiometricCredentialResource;
362388
try {
363-
const challenge = await user.__experimental_prepareBiometricCredential(enrollment);
389+
const challenge = await callApi(biometrics.fallbackCode, () =>
390+
user.__experimental_prepareBiometricCredential(enrollment),
391+
);
364392
const signature = await callModule(biometrics, module =>
365393
module.sign(key.localKeyId, challenge.clientData, params?.reason ?? DEFAULT_ENROLLMENT_REASON),
366394
);
367-
credential = await user.__experimental_attemptBiometricCredential({
368-
...enrollment,
369-
clientData: challenge.clientData,
370-
signature,
371-
});
395+
credential = await callApi(biometrics.fallbackCode, () =>
396+
user.__experimental_attemptBiometricCredential({
397+
...enrollment,
398+
clientData: challenge.clientData,
399+
signature,
400+
}),
401+
);
372402
} catch (error) {
373403
await ignoreErrors(() => biometrics.module.deleteKey(key.localKeyId));
374404
throw error;
@@ -401,7 +431,10 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential
401431

402432
revoke: async id => {
403433
const biometrics = requireBiometrics('E_TRUSTED_DEVICE_REVOCATION_FAILED');
404-
const credential = await requireUser(biometrics.fallbackCode).__experimental_revokeBiometricCredential(id);
434+
const user = requireUser(biometrics.fallbackCode);
435+
const credential = await callApi(biometrics.fallbackCode, () =>
436+
user.__experimental_revokeBiometricCredential(id),
437+
);
405438
await ignoreErrors(async () => {
406439
const records = await biometrics.module.listRecords();
407440
const localKeyIds = new Set(records.filter(record => record.id === id).map(record => record.localKeyId));
@@ -435,7 +468,7 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential
435468
if (isMissingCredentialError(error)) {
436469
await ignoreErrors(() => deleteLocalCredential(biometrics, record));
437470
}
438-
return error;
471+
return toApiBiometricCredentialError(error, biometrics.fallbackCode);
439472
};
440473

441474
let signIn: SignInResource;

0 commit comments

Comments
 (0)