From 6a025b966dfc3a45162f021c179565f105b66391 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 8 Oct 2026 09:48:28 -0700 Subject: [PATCH 1/4] feat(expo): add revokeCurrentDeviceCredential to useBiometricCredentials --- .../expo-revoke-current-device-credential.md | 13 +++++ .../__tests__/useBiometricCredentials.test.ts | 51 +++++++++++++++++++ .../createBiometricCredentials.ts | 48 ++++++++++++----- .../expo/src/biometric-credentials/types.ts | 1 + .../useBiometricCredentials.ts | 1 + 5 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 .changeset/expo-revoke-current-device-credential.md diff --git a/.changeset/expo-revoke-current-device-credential.md b/.changeset/expo-revoke-current-device-credential.md new file mode 100644 index 00000000000..43afc74af25 --- /dev/null +++ b/.changeset/expo-revoke-current-device-credential.md @@ -0,0 +1,13 @@ +--- +'@clerk/expo': minor +--- + +Add `revokeCurrentDeviceCredential()` to `useBiometricCredentials()`. It revokes the biometric credential enrolled by this app installation for the current user, so you no longer need to store the credential ID returned by `enroll()`. It resolves with the revoked credential, or `null` when this installation has no credential for the current user. + +```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..afa08d2fe19 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -731,6 +731,57 @@ 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('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..fa7a837b26f 100644 --- a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts +++ b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts @@ -277,6 +277,7 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential biometrics: Biometrics, id: string | undefined, identifierHint: string | undefined, + userId?: string, ): Promise { const unavailableReason = featureUnavailableReason(); if (unavailableReason) { @@ -294,6 +295,7 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential record => record.appIdentifier === biometrics.appIdentifier && (id === undefined || record.id === id) && + (userId === undefined || record.userId === userId) && matchesIdentifierHint(record), ) .sort(newestFirst); @@ -326,8 +328,9 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential biometrics: Biometrics, id: string | undefined, identifierHint: string | undefined, + userId?: string, ): Promise { - const candidates = await localCredentialCandidates(biometrics, id, identifierHint); + const candidates = await localCredentialCandidates(biometrics, id, identifierHint, userId); if (typeof candidates === 'string') { return { unavailableReason: candidates }; } @@ -448,6 +451,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'); @@ -545,18 +564,21 @@ 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), - ); - 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 revokeCredential(biometrics, requireUser(biometrics.fallbackCode), id); + }, + + revokeCurrentDeviceCredential: async () => { + const biometrics = requireBiometrics('E_TRUSTED_DEVICE_REVOCATION_FAILED'); + const session = clerk.session; + if (!session || (session.status !== 'active' && session.status !== 'pending') || !session.user) { + throw biometricCredentialError( + biometrics.fallbackCode, + 'Unable to revoke a biometric credential without an active or pending Clerk session.', + ); + } + const user = session.user; + const selection = await selectLocalCredential(biometrics, undefined, undefined, user.id); + return selection.record ? revokeCredential(biometrics, user, selection.record.id) : null; }, signIn: async params => { diff --git a/packages/expo/src/biometric-credentials/types.ts b/packages/expo/src/biometric-credentials/types.ts index 80817ab6a5e..501f1f42ba6 100644 --- a/packages/expo/src/biometric-credentials/types.ts +++ b/packages/expo/src/biometric-credentials/types.ts @@ -90,6 +90,7 @@ export type UseBiometricCredentialsReturn = { list: () => Promise; enroll: (params?: EnrollBiometricCredentialParams) => Promise; revoke: (id: string) => Promise; + 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, }); From 22fa3f7b23e93639afb264e2ab734439156a28a9 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 8 Oct 2026 10:13:38 -0700 Subject: [PATCH 2/4] fix(expo): revoke the current device credential without requiring biometric availability --- .../expo-revoke-current-device-credential.md | 2 +- .../__tests__/useBiometricCredentials.test.ts | 27 +++++++++++++++++++ .../createBiometricCredentials.ts | 23 +++++++++++----- .../expo/src/biometric-credentials/types.ts | 6 +++++ 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/.changeset/expo-revoke-current-device-credential.md b/.changeset/expo-revoke-current-device-credential.md index 43afc74af25..a9dcc8acde7 100644 --- a/.changeset/expo-revoke-current-device-credential.md +++ b/.changeset/expo-revoke-current-device-credential.md @@ -2,7 +2,7 @@ '@clerk/expo': minor --- -Add `revokeCurrentDeviceCredential()` to `useBiometricCredentials()`. It revokes the biometric credential enrolled by this app installation for the current user, so you no longer need to store the credential ID returned by `enroll()`. It resolves with the revoked credential, or `null` when this installation has no credential for the current user. +Add `revokeCurrentDeviceCredential()` to `useBiometricCredentials()`. It revokes the biometric credential enrolled by this app installation for the current user, so you no longer need to store the credential ID returned by `enroll()`. It resolves with the revoked credential, or `null` when this installation has no credential for the current user. Call it before signing out, because it requires an active or pending session. ```tsx import { useBiometricCredentials } from '@clerk/expo/biometrics'; diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts index afa08d2fe19..2f5d8129544 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -770,6 +770,33 @@ describe('revokeCurrentDeviceCredential', () => { 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(); diff --git a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts index fa7a837b26f..69c5b303c70 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 { @@ -277,7 +278,6 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential biometrics: Biometrics, id: string | undefined, identifierHint: string | undefined, - userId?: string, ): Promise { const unavailableReason = featureUnavailableReason(); if (unavailableReason) { @@ -295,7 +295,6 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential record => record.appIdentifier === biometrics.appIdentifier && (id === undefined || record.id === id) && - (userId === undefined || record.userId === userId) && matchesIdentifierHint(record), ) .sort(newestFirst); @@ -328,9 +327,8 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential biometrics: Biometrics, id: string | undefined, identifierHint: string | undefined, - userId?: string, ): Promise { - const candidates = await localCredentialCandidates(biometrics, id, identifierHint, userId); + const candidates = await localCredentialCandidates(biometrics, id, identifierHint); if (typeof candidates === 'string') { return { unavailableReason: candidates }; } @@ -577,8 +575,21 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential ); } const user = session.user; - const selection = await selectLocalCredential(biometrics, undefined, undefined, user.id); - return selection.record ? revokeCredential(biometrics, user, selection.record.id) : null; + 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; + } + await ignoreErrors(() => deleteLocalCredential(biometrics, record)); + return null; + } }, signIn: async params => { diff --git a/packages/expo/src/biometric-credentials/types.ts b/packages/expo/src/biometric-credentials/types.ts index 501f1f42ba6..0b308044b7c 100644 --- a/packages/expo/src/biometric-credentials/types.ts +++ b/packages/expo/src/biometric-credentials/types.ts @@ -90,6 +90,12 @@ export type UseBiometricCredentialsReturn = { list: () => Promise; enroll: (params?: EnrollBiometricCredentialParams) => Promise; revoke: (id: string) => Promise; + /** + * Revokes the biometric credential enrolled by this app installation for the current user. + * + * Call this before signing out, because it requires an active or pending session. Resolves with `null` when this + * installation has no credential for the current user. + */ revokeCurrentDeviceCredential: () => Promise; signIn: (params?: SignInWithBiometricsParams) => Promise; reverify: (params?: ReverifyWithBiometricsParams) => Promise; From 4f9803aa342e034e7882ca74dff5b6a73fb107e0 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 8 Oct 2026 10:14:10 -0700 Subject: [PATCH 3/4] refactor(expo): share the session check across biometric credential methods --- .../createBiometricCredentials.ts | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts index 69c5b303c70..dba1e8b9618 100644 --- a/packages/expo/src/biometric-credentials/createBiometricCredentials.ts +++ b/packages/expo/src/biometric-credentials/createBiometricCredentials.ts @@ -234,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)); } @@ -483,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( @@ -567,14 +571,10 @@ export function createBiometricCredentials(clerk: Clerk): UseBiometricCredential revokeCurrentDeviceCredential: async () => { const biometrics = requireBiometrics('E_TRUSTED_DEVICE_REVOCATION_FAILED'); - const session = clerk.session; - if (!session || (session.status !== 'active' && session.status !== 'pending') || !session.user) { - throw biometricCredentialError( - biometrics.fallbackCode, - 'Unable to revoke a biometric credential without an active or pending Clerk session.', - ); - } - const user = session.user; + const { user } = requireSessionWithUser( + biometrics.fallbackCode, + 'Unable to revoke a biometric credential without an active or pending Clerk session.', + ); const record = (await callModule(biometrics, module => module.listRecords())) .filter(record => record.appIdentifier === biometrics.appIdentifier && record.userId === user.id) .sort(newestFirst)[0]; @@ -664,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 })); From d9f17b3a42c834cd0f61b0600a67b5bbc929b576 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 8 Oct 2026 10:16:24 -0700 Subject: [PATCH 4/4] docs(expo): tighten revokeCurrentDeviceCredential wording --- .changeset/expo-revoke-current-device-credential.md | 2 +- packages/expo/src/biometric-credentials/types.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/expo-revoke-current-device-credential.md b/.changeset/expo-revoke-current-device-credential.md index a9dcc8acde7..a5c8966f0a6 100644 --- a/.changeset/expo-revoke-current-device-credential.md +++ b/.changeset/expo-revoke-current-device-credential.md @@ -2,7 +2,7 @@ '@clerk/expo': minor --- -Add `revokeCurrentDeviceCredential()` to `useBiometricCredentials()`. It revokes the biometric credential enrolled by this app installation for the current user, so you no longer need to store the credential ID returned by `enroll()`. It resolves with the revoked credential, or `null` when this installation has no credential for the current user. Call it before signing out, because it requires an active or pending session. +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'; diff --git a/packages/expo/src/biometric-credentials/types.ts b/packages/expo/src/biometric-credentials/types.ts index 0b308044b7c..9ba878d82d4 100644 --- a/packages/expo/src/biometric-credentials/types.ts +++ b/packages/expo/src/biometric-credentials/types.ts @@ -91,10 +91,10 @@ export type UseBiometricCredentialsReturn = { enroll: (params?: EnrollBiometricCredentialParams) => Promise; revoke: (id: string) => Promise; /** - * Revokes the biometric credential enrolled by this app installation for the current user. + * Revokes this app installation's biometric credential for the signed-in user. * - * Call this before signing out, because it requires an active or pending session. Resolves with `null` when this - * installation has no credential for the current 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;