diff --git a/.changeset/expo-revoke-current-device-credential.md b/.changeset/expo-revoke-current-device-credential.md new file mode 100644 index 00000000000..a5c8966f0a6 --- /dev/null +++ b/.changeset/expo-revoke-current-device-credential.md @@ -0,0 +1,13 @@ +--- +'@clerk/expo': minor +--- + +Add `revokeCurrentDeviceCredential()` to `useBiometricCredentials()`. It revokes this device's biometric credential for the signed-in user, so you no longer need to store the ID from `enroll()`. It returns the revoked credential, or `null` if there is nothing to revoke. Call it before signing out, since it needs a session. + +```tsx +import { useBiometricCredentials } from '@clerk/expo/biometrics'; + +const { revokeCurrentDeviceCredential } = useBiometricCredentials(); + +await revokeCurrentDeviceCredential(); +``` diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts index 51e27bfa2bc..2f5d8129544 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -731,6 +731,84 @@ describe('revoke', () => { }); }); +describe('revokeCurrentDeviceCredential', () => { + beforeEach(() => { + signInClerkUser(); + }); + + test("revokes this device's credential and deletes the local record", async () => { + addLocalCredential(); + + const credential = await renderBiometricCredentials().revokeCurrentDeviceCredential(); + + expect(clerk.user.__experimental_revokeBiometricCredential).toHaveBeenCalledWith('td_1'); + expect(biometrics.store.records).toEqual([]); + expect(credential).toMatchObject({ id: 'td_1', status: 'revoked', revokedAt: new Date(1_700_000_300_000) }); + }); + + test('resolves null without calling the server when there is no local credential', async () => { + await expect(renderBiometricCredentials().revokeCurrentDeviceCredential()).resolves.toBeNull(); + expect(clerk.user.__experimental_revokeBiometricCredential).not.toHaveBeenCalled(); + }); + + test.each(['active', 'pending'])("never revokes another user's credential when the session is %s", async status => { + signInClerkUser(status); + addLocalCredential({ id: 'td_other', localKeyId: 'key_other', userId: 'user_other', createdAt: 2 }); + + await expect(renderBiometricCredentials().revokeCurrentDeviceCredential()).resolves.toBeNull(); + expect(clerk.user.__experimental_revokeBiometricCredential).not.toHaveBeenCalled(); + expect(biometrics.store.records.map(record => record.id)).toEqual(['td_other']); + }); + + test("revokes the session user's credential when a newer record belongs to another user", async () => { + signInClerkUser('pending'); + addLocalCredential({ createdAt: 1 }); + addLocalCredential({ id: 'td_other', localKeyId: 'key_other', userId: 'user_other', createdAt: 2 }); + + await expect(renderBiometricCredentials().revokeCurrentDeviceCredential()).resolves.toMatchObject({ id: 'td_1' }); + expect(clerk.user.__experimental_revokeBiometricCredential).toHaveBeenCalledExactlyOnceWith('td_1'); + expect(biometrics.store.records.map(record => record.id)).toEqual(['td_other']); + }); + + test('revokes when biometrics are no longer available on the device', async () => { + addLocalCredential(); + biometrics.getAvailability.mockResolvedValue({ + biometryType: 'none', + canEvaluateBiometrics: false, + canEvaluateDeviceOwner: true, + errorCode: 'biometry_not_enrolled', + secureKeyStorageAvailable: true, + }); + + await expect(renderBiometricCredentials().revokeCurrentDeviceCredential()).resolves.toMatchObject({ id: 'td_1' }); + }); + + test('revokes the server credential when the local key is gone', async () => { + addLocalCredential({}, { withKey: false }); + + await expect(renderBiometricCredentials().revokeCurrentDeviceCredential()).resolves.toMatchObject({ id: 'td_1' }); + }); + + test('resolves null and deletes the local record when the server credential is already gone', async () => { + addLocalCredential(); + clerk.user.__experimental_revokeBiometricCredential.mockRejectedValue(apiError('resource_not_found')); + + await expect(renderBiometricCredentials().revokeCurrentDeviceCredential()).resolves.toBeNull(); + expect(biometrics.store.records).toEqual([]); + }); + + test('requires an active or pending session', async () => { + clerk.instance.session = null; + addLocalCredential(); + + await expect(renderBiometricCredentials().revokeCurrentDeviceCredential()).rejects.toMatchObject({ + code: 'E_TRUSTED_DEVICE_REVOCATION_FAILED', + message: 'Unable to revoke a biometric credential without an active or pending Clerk session.', + }); + expect(clerk.user.__experimental_revokeBiometricCredential).not.toHaveBeenCalled(); + }); +}); + describe('signIn', () => { test('signs the trusted device challenge and returns the completed sign-in', async () => { addLocalCredential(); diff --git a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts index e26fa68049b..dba1e8b9618 100644 --- a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts +++ b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts @@ -15,6 +15,7 @@ import { Platform } from 'react-native'; import { errorThrower } from '../utils/errors'; import type { BiometricCredentialError, BiometricCredentialErrorCode } from './errors'; +import { isBiometricCredentialError } from './errors'; import type { ExpoBiometricsModule, ExpoBiometricsRecord } from './loadExpoBiometrics'; import { loadExpoBiometrics } from './loadExpoBiometrics'; import type { @@ -233,6 +234,14 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential return user; } + function requireSessionWithUser(code: BiometricCredentialErrorCode, message: string) { + const session = clerk.session; + if (!session || (session.status !== 'active' && session.status !== 'pending') || !session.user) { + throw biometricCredentialError(code, message); + } + return { session, user: session.user }; + } + async function deleteLocalCredential(biometrics: Biometrics, record: ExpoBiometricsRecord): Promise { await callModule(biometrics, module => module.deleteRecord(record.localKeyId)); } @@ -448,6 +457,22 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential } } + async function revokeCredential( + biometrics: Biometrics, + user: UserResource, + id: string, + ): Promise { + 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)); + for (const localKeyId of localKeyIds) { + await biometrics.module.deleteRecord(localKeyId); + } + }); + return toBiometricCredential(credential); + } + return { getAvailability: async params => { const biometrics = requireBiometrics('E_TRUSTED_DEVICE_AVAILABILITY_FAILED'); @@ -466,14 +491,10 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential 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 { user } = requireSessionWithUser( + biometrics.fallbackCode, + 'Unable to enroll a biometric credential without an active or pending Clerk session.', + ); const unavailableReason = featureUnavailableReason(); if (unavailableReason) { throw biometricCredentialError( @@ -545,18 +566,30 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential revoke: async id => { const biometrics = requireBiometrics('E_TRUSTED_DEVICE_REVOCATION_FAILED'); - const user = requireUser(biometrics.fallbackCode); - const credential = await callApi(biometrics.fallbackCode, () => - user.__experimental_revokeBiometricCredential(id), + return revokeCredential(biometrics, requireUser(biometrics.fallbackCode), id); + }, + + revokeCurrentDeviceCredential: async () => { + const biometrics = requireBiometrics('E_TRUSTED_DEVICE_REVOCATION_FAILED'); + const { user } = requireSessionWithUser( + biometrics.fallbackCode, + 'Unable to revoke a biometric credential without an active or pending Clerk session.', ); - 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); + const record = (await callModule(biometrics, module => module.listRecords())) + .filter(record => record.appIdentifier === biometrics.appIdentifier && record.userId === user.id) + .sort(newestFirst)[0]; + if (!record) { + return null; + } + try { + return await revokeCredential(biometrics, user, record.id); + } catch (error) { + if (!isBiometricCredentialError(error) || error.code !== 'resource_not_found') { + throw error; } - }); - return toBiometricCredential(credential); + await ignoreErrors(() => deleteLocalCredential(biometrics, record)); + return null; + } }, signIn: async params => { @@ -631,14 +664,11 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential 'Biometric reverification level must be first_factor, second_factor, or multi_factor.', ); } - const session = clerk.session; - if (!session || (session.status !== 'active' && session.status !== 'pending') || !session.user) { - throw biometricCredentialError( - 'biometric_reverification_session_unavailable', - 'Biometric reverification requires an active or pending session with a user.', - ); - } - const record = await reverificationCredential(biometrics, session.user.id); + const { session, user } = requireSessionWithUser( + 'biometric_reverification_session_unavailable', + 'Biometric reverification requires an active or pending session with a user.', + ); + const record = await reverificationCredential(biometrics, user.id); const reason = params?.reason ?? DEFAULT_REVERIFICATION_REASON; let verification = await callApi(biometrics.fallbackCode, () => session.startVerification({ level })); diff --git a/packages/expo/src/biometric-credentials/types.ts b/packages/expo/src/biometric-credentials/types.ts index 80817ab6a5e..9ba878d82d4 100644 --- a/packages/expo/src/biometric-credentials/types.ts +++ b/packages/expo/src/biometric-credentials/types.ts @@ -90,6 +90,13 @@ export type UseBiometricCredentialsReturn = { list: () => Promise; enroll: (params?: EnrollBiometricCredentialParams) => Promise; revoke: (id: string) => Promise; + /** + * Revokes this app installation's biometric credential for the signed-in user. + * + * Call it before signing out, since it needs an active or pending session. Resolves with `null` if there is nothing + * to revoke. + */ + revokeCurrentDeviceCredential: () => Promise; signIn: (params?: SignInWithBiometricsParams) => Promise; reverify: (params?: ReverifyWithBiometricsParams) => Promise; }; diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.ts index bba07d38590..f76f0e1ed05 100644 --- a/packages/expo/src/biometric-credentials/useBiometricCredentials.ts +++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.ts @@ -19,6 +19,7 @@ const biometricCredentials: UseBiometricCredentialsReturn = Object.freeze({ list: rejectUnsupported, enroll: rejectUnsupported, revoke: rejectUnsupported, + revokeCurrentDeviceCredential: rejectUnsupported, signIn: rejectUnsupported, reverify: rejectUnsupported, });