From 982db33e035d57103a9cb2f12c5f0f5b9d6bc87f Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 18 Sep 2026 15:03:02 -0400 Subject: [PATCH 1/4] feat(expo): add biometric reverification Add useBiometricCredentials().reverify() for active sessions on iOS and Android, supporting first-, second-, and multi-factor verification with session synchronization and token refresh. Default new biometric enrollments to biometry_current_set while preserving existing credential policies. Support local native SDK overrides and add reverification tests and documentation. --- .../biometric-session-reverification.md | 7 + packages/expo/README.md | 29 ++- .../expo/modules/clerk/ClerkExpoModule.kt | 86 +++++++ .../clerk/BiometricCredentialBridgeTest.kt | 24 ++ packages/expo/ios/ClerkExpoModule.swift | 20 ++ packages/expo/ios/ClerkNativeBridge.swift | 51 ++++ .../ios/Tests/ClerkNativeBridgeTests.swift | 22 ++ .../__tests__/reverification.test.ts | 242 ++++++++++++++++++ .../__tests__/useBiometricCredentials.test.ts | 2 +- .../expo/src/biometric-credentials/errors.ts | 4 + .../expo/src/biometric-credentials/types.ts | 24 +- .../useBiometricCredentials.shared.ts | 54 +++- .../useBiometricCredentials.ts | 3 +- .../expo/src/specs/NativeClerkModule.types.ts | 14 +- 14 files changed, 575 insertions(+), 7 deletions(-) create mode 100644 .changeset/biometric-session-reverification.md create mode 100644 packages/expo/src/biometric-credentials/__tests__/reverification.test.ts diff --git a/.changeset/biometric-session-reverification.md b/.changeset/biometric-session-reverification.md new file mode 100644 index 00000000000..1bb7f0b740c --- /dev/null +++ b/.changeset/biometric-session-reverification.md @@ -0,0 +1,7 @@ +--- +'@clerk/expo': minor +--- + +Add `useBiometricCredentials().reverify()` for first-, second-, and multi-factor verification of the active session on iOS and Android. Successful verification refreshes the JavaScript session token without creating a new session. + +New biometric enrollments default to `biometry_current_set`, requiring biometrics without device-passcode fallback. Existing credentials keep their original policy; Android reverification requires the stronger policy and returns `biometric_credential_policy_incompatible` for older, weaker credentials. Apps should offer another verification method in that case. Reverification requires an updated native development build. diff --git a/packages/expo/README.md b/packages/expo/README.md index e8e3370bfc0..479f0ad2b89 100644 --- a/packages/expo/README.md +++ b/packages/expo/README.md @@ -56,9 +56,36 @@ Biometric credential operations preserve Clerk API and native biometric error co `useTrustedDevices()` and the trusted-device types remain available as deprecated compatibility aliases. New code should use `useBiometricCredentials()` and the biometric credential types; the previous `deviceName` enrollment option is forwarded to `name`. +New enrollments default to `biometry_current_set`: biometric authentication is required, with no device-passcode fallback, and adding a biometric invalidates the app's key. Explicit alternative policies remain available for sign-in. Existing credentials retain their original protection. + +#### Biometric reverification + +Use `reverify()` to verify the active session before a sensitive action without signing the user out or creating another session: + +```tsx +const { reverify } = useBiometricCredentials(); + +const result = await reverify({ + level: 'multi_factor', + reason: 'Verify your identity to update your account.', +}); + +if (result.status === 'complete') { + await updateAccount(); +} +``` + +`level` defaults to `first_factor`; `second_factor` and `multi_factor` are also supported. The native SDK starts verification and follows the factor requested by the server. The result includes the server's actual `level`, `status`, and synchronized JavaScript `session`. Check `status` before continuing; an incomplete result can be continued using that session's verification methods. + +On completion, Expo clears cached JavaScript session tokens and fetches a fresh token before resolving. The server remains responsible for enforcing the sensitive action's reverification requirement. + +Android requires a credential originally enrolled with `biometry_current_set` for reverification. Older or explicitly weaker credentials return `biometric_credential_policy_incompatible`; offer another verification method. They remain available for sign-in and are not automatically replaced. iOS uses the protection selected when the credential was enrolled. + +Reverification requires a development build containing the updated native bridge. An over-the-air JavaScript update alone cannot add it to an older native build. Biometric cancellation, invalidated-key errors, and Clerk API error codes are preserved. + #### Face ID on iOS -Apps that use Face ID for biometric credential enrollment or sign-in must provide `NSFaceIDUsageDescription`. You can have the Clerk config plugin add it during prebuild: +Apps that use Face ID for biometric credential enrollment, sign-in, or reverification must provide `NSFaceIDUsageDescription`. You can have the Clerk config plugin add it during prebuild: ```json { diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index b0f0255db04..718e5b16e19 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -18,6 +18,9 @@ import com.clerk.api.biometriccredential.BiometricCredential import com.clerk.api.biometriccredential.BiometricCredentialAvailability import com.clerk.api.biometriccredential.BiometricCredentialKeyManagerException import com.clerk.api.biometriccredential.BiometricCredentialPolicy +import com.clerk.api.session.SessionVerification +import com.clerk.api.session.startVerification +import com.clerk.api.session.verifyWithBiometrics import com.clerk.api.ui.ClerkColors import com.clerk.api.ui.ClerkDesign import com.clerk.api.ui.ClerkTheme @@ -103,6 +106,23 @@ internal fun biometricCredentialPolicy(policy: String): BiometricCredentialPolic } } +internal fun biometricReverificationLevel(level: String): SessionVerification.Level? = when (level) { + "first_factor" -> SessionVerification.Level.FIRST_FACTOR + "second_factor" -> SessionVerification.Level.SECOND_FACTOR + "multi_factor" -> SessionVerification.Level.MULTI_FACTOR + else -> null +} + +internal fun biometricReverificationPayload( + verification: SessionVerification, + sessionId: String +): Map = mapOf( + "id" to verification.id, + "status" to verification.status.name.lowercase(), + "level" to verification.level.value, + "sessionId" to (verification.session?.id ?: sessionId) +) + internal data class BiometricCredentialBridgeError( val code: String, val message: String @@ -271,6 +291,14 @@ class ClerkExpoModule : Module() { promise: Promise -> signInWithBiometrics(id, identifierHint, reason, promise) } + + AsyncFunction("reverifyWithBiometrics") { + sessionId: String, + level: String, + reason: String?, + promise: Promise -> + reverifyWithBiometrics(sessionId, level, reason, promise) + } } private val reactContext: Context? @@ -756,6 +784,64 @@ class ClerkExpoModule : Module() { } } + private fun reverifyWithBiometrics( + sessionId: String, + level: String, + reason: String?, + promise: Promise + ) { + if (!requireBiometricCredentialEnvironment(promise)) return + val requestedLevel = biometricReverificationLevel(level) + if (requestedLevel == null) { + promise.reject("invalid_reverification_level", "Invalid biometric reverification level: $level", null) + return + } + coroutineScope.launch { + try { + val session = Clerk.clientFlow.value?.sessions?.firstOrNull { it.id == sessionId } + if (session == null) { + promise.reject( + "biometric_reverification_session_unavailable", + "The session to reverify is unavailable in the native Clerk client.", + null + ) + return@launch + } + if (!attachCurrentActivityForBiometricCredential(promise)) return@launch + val started = when (val result = session.startVerification(requestedLevel)) { + is ClerkResult.Success -> result.value + is ClerkResult.Failure -> { + rejectBiometricCredentialFailure(promise, "E_BIOMETRIC_REVERIFICATION_FAILED", + "Unable to start biometric reverification", result) + return@launch + } + } + val factor = when (started.status) { + SessionVerification.Status.NEEDS_FIRST_FACTOR -> SessionVerification.Level.FIRST_FACTOR + SessionVerification.Status.NEEDS_SECOND_FACTOR -> SessionVerification.Level.SECOND_FACTOR + SessionVerification.Status.COMPLETE -> { + promise.resolve(biometricReverificationPayload(started, sessionId)) + return@launch + } + SessionVerification.Status.UNKNOWN -> { + promise.reject("E_BIOMETRIC_REVERIFICATION_FAILED", + "The server returned an unsupported reverification status.", null) + return@launch + } + } + when (val result = session.verifyWithBiometrics(promptSubtitle = reason, level = factor)) { + is ClerkResult.Success -> promise.resolve(biometricReverificationPayload(result.value, sessionId)) + is ClerkResult.Failure -> rejectBiometricCredentialFailure( + promise, "E_BIOMETRIC_REVERIFICATION_FAILED", "Unable to reverify with biometrics", result + ) + } + } catch (e: Exception) { + rejectBiometricCredentialException(promise, "E_BIOMETRIC_REVERIFICATION_FAILED", + "Unable to reverify with biometrics", e) + } + } + } + private fun requireBiometricCredentialEnvironment(promise: Promise): Boolean { val error = biometricCredentialEnvironmentError(Clerk.isInitialized.value) ?: return true promise.reject(error.code, error.message, null) diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt b/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt index a9784379073..886fd737c28 100644 --- a/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt +++ b/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt @@ -8,11 +8,35 @@ import com.clerk.api.biometriccredential.BiometricCredential import com.clerk.api.biometriccredential.BiometricCredentialAvailability import com.clerk.api.biometriccredential.BiometricCredentialKeyManagerException import com.clerk.api.biometriccredential.BiometricCredentialPolicy +import com.clerk.api.session.SessionVerification import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test class BiometricCredentialBridgeTest { + @Test + fun `maps biometric reverification requirements`() { + assertEquals(SessionVerification.Level.FIRST_FACTOR, biometricReverificationLevel("first_factor")) + assertEquals(SessionVerification.Level.SECOND_FACTOR, biometricReverificationLevel("second_factor")) + assertEquals(SessionVerification.Level.MULTI_FACTOR, biometricReverificationLevel("multi_factor")) + assertNull(biometricReverificationLevel("unknown")) + } + + @Test + fun `maps reverification results without creating a session`() { + val result = SessionVerification( + id = "stepup_test", + status = SessionVerification.Status.COMPLETE, + level = SessionVerification.Level.MULTI_FACTOR + ) + assertEquals(mapOf( + "id" to "stepup_test", + "status" to "complete", + "level" to "multi_factor", + "sessionId" to "sess_test" + ), biometricReverificationPayload(result, "sess_test")) + } + private fun keyManagerException( code: BiometricCredentialKeyManagerException.Code, message: String diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index a402341d9d4..a1a12679066 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -100,6 +100,26 @@ public class ClerkExpoModule: Module { promise: promise ) } + + AsyncFunction("reverifyWithBiometrics") { + (sessionId: String, level: String, reason: String?, promise: Promise) in + Task { @MainActor in + do { + let verification = try await ClerkNativeBridge.shared.reverifyWithBiometrics( + sessionId: sessionId, + level: level, + reason: reason + ) + promise.resolve(verification) + } catch { + self.rejectBiometricCredentialError( + error, + fallbackCode: "E_BIOMETRIC_REVERIFICATION_FAILED", + promise: promise + ) + } + } + } } // MARK: - configure diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index c90ad2ef116..873db5c9878 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -833,6 +833,57 @@ final class ClerkNativeBridge { } } + @MainActor + func reverifyWithBiometrics(sessionId: String, level: String, reason: String?) async throws -> [String: Any] { + try Self.requireBiometricCredentialEnvironment() + let requestedLevel = try Self.biometricReverificationLevel(level) + guard let session = Clerk.shared.client?.sessions.first(where: { $0.id == sessionId }) else { + throw ClerkExpoBiometricCredentialError( + code: "biometric_reverification_session_unavailable", + message: "The session to reverify is unavailable in the native Clerk client." + ) + } + + let started = try await session.startVerification(level: requestedLevel) + let verification: SessionVerification + switch started.status { + case .needsFirstFactor: + verification = try await session.verifyWithBiometrics(reason: reason, level: .firstFactor) + case .needsSecondFactor: + verification = try await session.verifyWithBiometrics(reason: reason, level: .secondFactor) + case .complete: + verification = started + case .unknown: + throw ClerkExpoBiometricCredentialError( + code: "E_BIOMETRIC_REVERIFICATION_FAILED", + message: "The server returned an unsupported reverification status." + ) + } + return Self.biometricReverificationPayload(verification, sessionId: sessionId) + } + + static func biometricReverificationLevel(_ level: String) throws -> SessionVerification.Level { + switch level { + case "first_factor": .firstFactor + case "second_factor": .secondFactor + case "multi_factor": .multiFactor + default: + throw ClerkExpoBiometricCredentialError( + code: "invalid_reverification_level", + message: "Biometric reverification level must be first_factor, second_factor, or multi_factor." + ) + } + } + + static func biometricReverificationPayload(_ verification: SessionVerification, sessionId: String) -> [String: Any] { + [ + "id": bridgeValue(verification.id), + "status": verification.status.rawValue, + "level": verification.level.rawValue, + "sessionId": verification.session?.id ?? sessionId, + ] + } + private static func biometricCredentialPayload(_ biometricCredential: BiometricCredential) -> [String: Any] { [ "id": biometricCredential.id, diff --git a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift b/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift index de096e0ea70..03bec9e7536 100644 --- a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift +++ b/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift @@ -1,4 +1,5 @@ import XCTest +import ClerkKit @testable import ClerkExpo final class ClerkNativeBridgeTests: XCTestCase { @@ -15,6 +16,11 @@ final class ClerkNativeBridgeTests: XCTestCase { @MainActor func testBiometricCredentialOperationsRejectBeforeConfiguration() async { + await assertEnvironmentUnavailable { + try await ClerkNativeBridge.shared.reverifyWithBiometrics( + sessionId: "sess_test", level: "multi_factor", reason: nil + ) + } await assertEnvironmentUnavailable { try await ClerkNativeBridge.shared.listBiometricCredentials() } @@ -38,6 +44,22 @@ final class ClerkNativeBridgeTests: XCTestCase { } } + func testBiometricReverificationLevels() throws { + XCTAssertEqual(try ClerkNativeBridge.biometricReverificationLevel("first_factor"), .firstFactor) + XCTAssertEqual(try ClerkNativeBridge.biometricReverificationLevel("second_factor"), .secondFactor) + XCTAssertEqual(try ClerkNativeBridge.biometricReverificationLevel("multi_factor"), .multiFactor) + XCTAssertThrowsError(try ClerkNativeBridge.biometricReverificationLevel("unknown")) + } + + func testBiometricReverificationPayloadWithoutEmbeddedSession() { + let verification = SessionVerification(id: "stepup_test", status: .complete, level: .multiFactor) + let payload = ClerkNativeBridge.biometricReverificationPayload(verification, sessionId: "sess_test") + XCTAssertEqual(payload["id"] as? String, "stepup_test") + XCTAssertEqual(payload["status"] as? String, "complete") + XCTAssertEqual(payload["level"] as? String, "multi_factor") + XCTAssertEqual(payload["sessionId"] as? String, "sess_test") + } + @MainActor private func assertEnvironmentUnavailable( _ operation: @MainActor () async throws -> Any, diff --git a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts new file mode 100644 index 00000000000..75978520dd1 --- /dev/null +++ b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts @@ -0,0 +1,242 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + __internal_resetNativeClientSyncCoordinator, + registerNativeToJsSyncHandler, + trackPendingJsToNativeSync, +} from '../../provider/nativeClientSyncCoordinator'; +import { useBiometricCredentials as useUnsupportedBiometrics } from '../useBiometricCredentials'; +import { useBiometricCredentials as useAndroidBiometrics } from '../useBiometricCredentials.android'; +import { useBiometricCredentials as useIosBiometrics } from '../useBiometricCredentials.ios'; + +const mocks = vi.hoisted(() => ({ + useClerk: vi.fn(), + nativeModule: { + getTrustedDeviceAvailability: vi.fn(), + listTrustedDevices: vi.fn(), + enrollTrustedDevice: vi.fn(), + revokeTrustedDevice: vi.fn(), + signInWithTrustedDevice: vi.fn(), + reverifyWithBiometrics: vi.fn(), + }, +})); + +vi.mock('@clerk/react', () => ({ useClerk: mocks.useClerk })); +vi.mock('../../utils/native-module', () => ({ ClerkExpoModule: mocks.nativeModule })); +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })); + +const session = { + id: 'sess_123', + clearCache: vi.fn(), + getToken: vi.fn(), +}; +const clerk = { session: session as typeof session | null, setActive: vi.fn() }; +const synchronize = vi.fn(); +let unregister: () => void; + +beforeEach(() => { + vi.resetAllMocks(); + __internal_resetNativeClientSyncCoordinator(); + unregister = registerNativeToJsSyncHandler(synchronize); + clerk.session = session; + mocks.useClerk.mockReturnValue(clerk); + session.getToken.mockResolvedValue('fresh-token'); + mocks.nativeModule.reverifyWithBiometrics.mockResolvedValue({ + id: 'stepup_123', + status: 'complete', + level: 'first_factor', + sessionId: session.id, + }); +}); + +afterEach(() => unregister()); + +describe.each([ + ['iOS', useIosBiometrics], + ['Android', useAndroidBiometrics], +] as const)('biometric reverification on %s', (_platform, useBiometrics) => { + test('refreshes the same session and forces a fresh token before completing', async () => { + const { result } = renderHook(useBiometrics); + + await expect(result.current.reverify({ reason: 'Approve this change' })).resolves.toEqual({ + id: 'stepup_123', + status: 'complete', + level: 'first_factor', + session, + }); + expect(mocks.nativeModule.reverifyWithBiometrics).toHaveBeenCalledWith( + session.id, + 'first_factor', + 'Approve this change', + ); + expect(synchronize).toHaveBeenCalledOnce(); + expect(session.clearCache).toHaveBeenCalledTimes(2); + expect(session.clearCache.mock.invocationCallOrder[0]).toBeLessThan(synchronize.mock.invocationCallOrder[0]); + 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(); + }); + + test.each(['first_factor', 'second_factor', 'multi_factor'] as const)( + 'passes the requested %s requirement', + async level => { + const { result } = renderHook(useBiometrics); + await result.current.reverify({ level }); + expect(mocks.nativeModule.reverifyWithBiometrics).toHaveBeenCalledWith(session.id, level, null); + }, + ); +}); + +test('returns the server level when it differs from the requested level', async () => { + const { result } = renderHook(useIosBiometrics); + await expect(result.current.reverify({ level: 'multi_factor' })).resolves.toMatchObject({ level: 'first_factor' }); +}); + +test('incomplete verification returns the synchronized session without refreshing tokens', async () => { + mocks.nativeModule.reverifyWithBiometrics.mockResolvedValue({ + id: 'stepup_123', + status: 'needs_second_factor', + level: 'multi_factor', + sessionId: session.id, + }); + const refreshedSession = { ...session }; + synchronize.mockImplementation(() => { + clerk.session = refreshedSession; + }); + const { result } = renderHook(useIosBiometrics); + const verification = await result.current.reverify({ level: 'multi_factor' }); + expect(verification.status).toBe('needs_second_factor'); + expect(verification.session).toBe(refreshedSession); + expect(session.clearCache).not.toHaveBeenCalled(); + expect(session.getToken).not.toHaveBeenCalled(); +}); + +test('waits for pending JS-to-native synchronization', async () => { + let finish!: () => void; + trackPendingJsToNativeSync( + new Promise(resolve => { + finish = resolve; + }), + ); + const { result } = renderHook(useIosBiometrics); + const verification = result.current.reverify(); + await Promise.resolve(); + expect(mocks.nativeModule.reverifyWithBiometrics).not.toHaveBeenCalled(); + finish(); + await verification; +}); + +test('does not start a biometric operation after the active session changes during synchronization', async () => { + let finish!: () => void; + trackPendingJsToNativeSync( + new Promise(resolve => { + finish = resolve; + }), + ); + const { result } = renderHook(useIosBiometrics); + const verification = result.current.reverify(); + clerk.session = { ...session, id: 'sess_other' }; + finish(); + await expect(verification).rejects.toThrow('active session changed'); + expect(mocks.nativeModule.reverifyWithBiometrics).not.toHaveBeenCalled(); +}); + +test.each(['biometric_authentication_canceled', 'biometric_credential_policy_incompatible', 'key_invalidated'])( + 'preserves the native %s error and leaves the session cache intact', + async code => { + const error = Object.assign(new Error(code), { code }); + mocks.nativeModule.reverifyWithBiometrics.mockRejectedValue(error); + const { result } = renderHook(useAndroidBiometrics); + await expect(result.current.reverify()).rejects.toBe(error); + expect(session.clearCache).not.toHaveBeenCalled(); + expect(session.getToken).not.toHaveBeenCalled(); + expect(synchronize).not.toHaveBeenCalled(); + }, +); + +test('rejects a native result belonging to another session', async () => { + mocks.nativeModule.reverifyWithBiometrics.mockResolvedValue({ status: 'complete', sessionId: 'sess_other' }); + const { result } = renderHook(useIosBiometrics); + await expect(result.current.reverify()).rejects.toThrow('different session'); + expect(synchronize).not.toHaveBeenCalled(); +}); + +test('does not refresh or activate another session if the active session changes', async () => { + const otherSession = { ...session, id: 'sess_other', getToken: vi.fn() }; + synchronize.mockImplementation(() => { + clerk.session = otherSession; + }); + const { result } = renderHook(useIosBiometrics); + await expect(result.current.reverify()).rejects.toThrow('active session changed'); + expect(otherSession.getToken).not.toHaveBeenCalled(); + expect(clerk.setActive).not.toHaveBeenCalled(); +}); + +test('propagates synchronization failures after invalidating cached tokens', async () => { + synchronize.mockRejectedValue(new Error('sync failed')); + const { result } = renderHook(useIosBiometrics); + await expect(result.current.reverify()).rejects.toThrow('sync failed'); + expect(session.clearCache).toHaveBeenCalledOnce(); + expect(session.getToken).not.toHaveBeenCalled(); +}); + +test.each([null, 'failure'])('does not report success when token refresh returns %s', async outcome => { + if (outcome === null) { + session.getToken.mockResolvedValue(null); + } else { + session.getToken.mockRejectedValue(new Error('token refresh failed')); + } + const { result } = renderHook(useIosBiometrics); + await expect(result.current.reverify()).rejects.toThrow(); +}); + +test('requires an active session', async () => { + clerk.session = null; + const { result } = renderHook(useIosBiometrics); + await expect(result.current.reverify()).rejects.toThrow('requires an active session'); + expect(mocks.nativeModule.reverifyWithBiometrics).not.toHaveBeenCalled(); +}); + +test('rejects an invalid runtime level before starting verification', async () => { + const { result } = renderHook(useIosBiometrics); + await expect(result.current.reverify({ level: 'invalid' as 'first_factor' })).rejects.toThrow( + 'level must be first_factor, second_factor, or multi_factor', + ); + expect(mocks.nativeModule.reverifyWithBiometrics).not.toHaveBeenCalled(); +}); + +test('rejects completion if the active session changes while refreshing its token', async () => { + let finish!: (token: string) => void; + session.getToken.mockReturnValue( + new Promise(resolve => { + finish = resolve; + }), + ); + const { result } = renderHook(useIosBiometrics); + const verification = result.current.reverify(); + await vi.waitFor(() => expect(session.getToken).toHaveBeenCalled()); + clerk.session = { ...session, id: 'sess_other' }; + finish('fresh-token'); + await expect(verification).rejects.toThrow('active session changed'); + expect(clerk.setActive).not.toHaveBeenCalled(); +}); + +test('older native builds keep existing operations but explain the missing reverification method', 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('unsupported platforms reject reverification', async () => { + const { result } = renderHook(useUnsupportedBiometrics); + await expect(result.current.reverify()).rejects.toThrow('only available on iOS and Android'); +}); diff --git a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts index 7789d29c744..dee295ff1a5 100644 --- a/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts +++ b/packages/expo/src/biometric-credentials/__tests__/useBiometricCredentials.test.ts @@ -217,7 +217,7 @@ describe('useBiometricCredentials on iOS', () => { "Sean's iPhone", 'sean@example.com', 'Use Face ID to trust this device.', - 'biometry_or_device_passcode', + 'biometry_current_set', ); expect(biometricCredential.createdAt).toEqual(new Date(nativeBiometricCredential.createdAt)); }); diff --git a/packages/expo/src/biometric-credentials/errors.ts b/packages/expo/src/biometric-credentials/errors.ts index f52a8f1eed4..f0a59f446e5 100644 --- a/packages/expo/src/biometric-credentials/errors.ts +++ b/packages/expo/src/biometric-credentials/errors.ts @@ -13,6 +13,10 @@ export type BiometricCredentialErrorCode = | 'signing_failed' | 'key_deletion_failed' | 'invalid_trusted_device_policy' + | 'biometric_credential_policy_incompatible' + | 'invalid_reverification_level' + | 'biometric_reverification_session_unavailable' + | 'E_BIOMETRIC_REVERIFICATION_FAILED' | 'E_TRUSTED_DEVICE_AVAILABILITY_FAILED' | 'E_TRUSTED_DEVICE_LIST_FAILED' | 'E_TRUSTED_DEVICE_ENROLLMENT_FAILED' diff --git a/packages/expo/src/biometric-credentials/types.ts b/packages/expo/src/biometric-credentials/types.ts index 836a9bd844e..302f2f8d613 100644 --- a/packages/expo/src/biometric-credentials/types.ts +++ b/packages/expo/src/biometric-credentials/types.ts @@ -1,4 +1,11 @@ -import type { SetActive, SignInResource, SignInStatus } from '@clerk/shared/types'; +import type { + SessionResource, + SessionVerificationLevel, + SessionVerificationStatus, + SetActive, + SignInResource, + SignInStatus, +} from '@clerk/shared/types'; export type BiometricCredentialUnavailableReason = | 'environment_unavailable' @@ -55,6 +62,20 @@ export type SignInWithBiometricsParams = { reason?: string; }; +/** Options for starting biometric reverification of the active session. */ +export type ReverifyWithBiometricsParams = { + level?: SessionVerificationLevel; + reason?: string; +}; + +/** The verification outcome and synchronized session, without creating or activating a session. */ +export type BiometricReverificationResult = { + id: string | null; + status: SessionVerificationStatus | (string & {}); + level: SessionVerificationLevel | (string & {}); + session: SessionResource; +}; + export type BiometricSignInResult = { status: SignInStatus | (string & {}); createdSessionId: string | null; @@ -70,4 +91,5 @@ export type UseBiometricCredentialsReturn = { enroll: (params?: EnrollBiometricCredentialParams) => Promise; revoke: (id: string) => Promise; signIn: (params?: SignInWithBiometricsParams) => Promise; + reverify: (params?: ReverifyWithBiometricsParams) => Promise; }; diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts index 6e2248d3129..3e92e5287be 100644 --- a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts +++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts @@ -12,7 +12,7 @@ import type { UseBiometricCredentialsReturn, } from './types'; -const DEFAULT_POLICY = 'biometry_or_device_passcode'; +const DEFAULT_POLICY = 'biometry_current_set'; function toBiometricCredentialPlatform(platform: string): BiometricCredentialPlatform { return platform === 'ios' || platform === 'android' ? platform : 'unknown'; @@ -82,6 +82,56 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom const credential = await nativeModule.revokeTrustedDevice(id); return toBiometricCredential(credential); }, + reverify: async params => { + const nativeModule = getNativeModule(); + if (typeof nativeModule.reverifyWithBiometrics !== 'function') { + return errorThrower.throw( + 'Biometric reverification requires a development build containing a compatible version of @clerk/expo.', + ); + } + const level = params?.level ?? 'first_factor'; + if (level !== 'first_factor' && level !== 'second_factor' && level !== 'multi_factor') { + return errorThrower.throw( + 'Biometric reverification level must be first_factor, second_factor, or multi_factor.', + ); + } + const session = clerk.session; + if (!session) { + return errorThrower.throw('Biometric reverification requires an active session.'); + } + await waitForPendingJsToNativeSync(); + if (clerk.session?.id !== session.id) { + return errorThrower.throw('The active session changed before biometric reverification started.'); + } + const verification = await nativeModule.reverifyWithBiometrics(session.id, level, params?.reason ?? null); + if (verification.sessionId !== session.id) { + return errorThrower.throw('Biometric reverification returned a different session.'); + } + if (verification.status === 'complete') { + session.clearCache(); + } + await synchronizeNativeClientToJs(); + const synchronizedSession = clerk.session; + if (synchronizedSession?.id !== session.id) { + return errorThrower.throw('The active session changed during biometric reverification.'); + } + if (verification.status === 'complete') { + synchronizedSession.clearCache(); + const token = await synchronizedSession.getToken({ skipCache: true }); + if (!token) { + return errorThrower.throw('Unable to refresh the session token after biometric reverification.'); + } + if (clerk.session?.id !== session.id) { + return errorThrower.throw('The active session changed during biometric reverification.'); + } + } + return { + id: verification.id, + status: verification.status, + level: verification.level, + session: synchronizedSession, + }; + }, signIn: async params => { const nativeModule = getNativeModule(); await waitForPendingJsToNativeSync(); @@ -129,7 +179,7 @@ function createBiometricCredentials(clerk: ReturnType): UseBiom } /** - * Accesses biometric credential enrollment and sign-in on iOS and Android. + * Accesses biometric credential enrollment, sign-in, and session reverification on iOS and Android. * * The private key and biometric prompt are managed by Clerk's native SDK. */ diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.ts index 4c5140944ef..bb3a824e016 100644 --- a/packages/expo/src/biometric-credentials/useBiometricCredentials.ts +++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.ts @@ -20,10 +20,11 @@ const biometricCredentials: UseBiometricCredentialsReturn = Object.freeze({ enroll: rejectUnsupported, revoke: rejectUnsupported, signIn: rejectUnsupported, + reverify: rejectUnsupported, }); /** - * Accesses biometric credential enrollment and sign-in. + * Accesses biometric credential enrollment, sign-in, and session reverification. * * Biometric credentials are currently supported on iOS and Android. */ diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts index cff3066b8c0..fa3aa35b6e2 100644 --- a/packages/expo/src/specs/NativeClerkModule.types.ts +++ b/packages/expo/src/specs/NativeClerkModule.types.ts @@ -1,4 +1,4 @@ -import type { SignInStatus } from '@clerk/shared/types'; +import type { SessionVerificationLevel, SessionVerificationStatus, SignInStatus } from '@clerk/shared/types'; import type { BiometricCredentialAvailability, BiometricCredentialPolicy } from '../biometric-credentials/types'; @@ -31,6 +31,13 @@ export type NativeBiometricSignInResult = { createdSessionId: string | null; }; +export type NativeBiometricReverificationResult = { + id: string | null; + status: SessionVerificationStatus | (string & {}); + level: SessionVerificationLevel | (string & {}); + sessionId: string; +}; + export type NativeBiometricCredentialModule = { getTrustedDeviceAvailability( id: string | null, @@ -49,4 +56,9 @@ export type NativeBiometricCredentialModule = { identifierHint: string | null, reason: string | null, ): Promise; + reverifyWithBiometrics( + sessionId: string, + level: SessionVerificationLevel, + reason: string | null, + ): Promise; }; From 6f9368baa1a6cdc690f80740fa00a6464173c219 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 21 Sep 2026 17:54:22 -0400 Subject: [PATCH 2/4] docs(expo): remove biometric section from package README --- packages/expo/README.md | 70 ----------------------------------------- 1 file changed, 70 deletions(-) diff --git a/packages/expo/README.md b/packages/expo/README.md index 479f0ad2b89..1fa4fbb447f 100644 --- a/packages/expo/README.md +++ b/packages/expo/README.md @@ -48,76 +48,6 @@ You'll learn how to create an Expo application, install `@clerk/expo`, set up yo For further information, guides, and examples visit the [Expo reference documentation](https://clerk.com/docs/references/expo/overview?utm_source=github&utm_medium=clerk_expo). -### Biometric sign-in - -Biometric credential enrollment and sign-in are supported in development builds on iOS and Android. Android requires Android 9 (API 28) or later and an enrolled Class 3 biometric. - -Biometric credential operations preserve Clerk API and native biometric error codes. Use `isBiometricCredentialError(error)` to safely inspect `error.code`; unrecognized error codes remain available for forward compatibility, while unfamiliar platform and status values are normalized to `unknown`. - -`useTrustedDevices()` and the trusted-device types remain available as deprecated compatibility aliases. New code should use `useBiometricCredentials()` and the biometric credential types; the previous `deviceName` enrollment option is forwarded to `name`. - -New enrollments default to `biometry_current_set`: biometric authentication is required, with no device-passcode fallback, and adding a biometric invalidates the app's key. Explicit alternative policies remain available for sign-in. Existing credentials retain their original protection. - -#### Biometric reverification - -Use `reverify()` to verify the active session before a sensitive action without signing the user out or creating another session: - -```tsx -const { reverify } = useBiometricCredentials(); - -const result = await reverify({ - level: 'multi_factor', - reason: 'Verify your identity to update your account.', -}); - -if (result.status === 'complete') { - await updateAccount(); -} -``` - -`level` defaults to `first_factor`; `second_factor` and `multi_factor` are also supported. The native SDK starts verification and follows the factor requested by the server. The result includes the server's actual `level`, `status`, and synchronized JavaScript `session`. Check `status` before continuing; an incomplete result can be continued using that session's verification methods. - -On completion, Expo clears cached JavaScript session tokens and fetches a fresh token before resolving. The server remains responsible for enforcing the sensitive action's reverification requirement. - -Android requires a credential originally enrolled with `biometry_current_set` for reverification. Older or explicitly weaker credentials return `biometric_credential_policy_incompatible`; offer another verification method. They remain available for sign-in and are not automatically replaced. iOS uses the protection selected when the credential was enrolled. - -Reverification requires a development build containing the updated native bridge. An over-the-air JavaScript update alone cannot add it to an older native build. Biometric cancellation, invalidated-key errors, and Clerk API error codes are preserved. - -#### Face ID on iOS - -Apps that use Face ID for biometric credential enrollment, sign-in, or reverification must provide `NSFaceIDUsageDescription`. You can have the Clerk config plugin add it during prebuild: - -```json -{ - "expo": { - "plugins": [ - [ - "@clerk/expo", - { - "faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID for secure sign-in." - } - ] - ] - } -} -``` - -The plugin only adds the permission when `faceIDPermission` is provided and does not overwrite `ios.infoPlist.NSFaceIDUsageDescription` if your app already defines it. - -You can also configure the key directly: - -```json -{ - "expo": { - "ios": { - "infoPlist": { - "NSFaceIDUsageDescription": "Allow $(PRODUCT_NAME) to use Face ID for secure sign-in." - } - } - } -} -``` - ## Support For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo). From 75cef84da0321299bd8a6f382527406993514db3 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 23 Sep 2026 09:12:02 -0400 Subject: [PATCH 3/4] fix(expo): continue biometric reverification across factors --- .../biometric-session-reverification.md | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 38 +++--- .../clerk/BiometricCredentialBridgeTest.kt | 102 +++++++++++++++++ packages/expo/ios/ClerkNativeBridge.swift | 29 ++++- .../ios/Tests/ClerkNativeBridgeTests.swift | 108 ++++++++++++++++++ 5 files changed, 260 insertions(+), 19 deletions(-) diff --git a/.changeset/biometric-session-reverification.md b/.changeset/biometric-session-reverification.md index 1bb7f0b740c..ac15ce90fe8 100644 --- a/.changeset/biometric-session-reverification.md +++ b/.changeset/biometric-session-reverification.md @@ -2,6 +2,6 @@ '@clerk/expo': minor --- -Add `useBiometricCredentials().reverify()` for first-, second-, and multi-factor verification of the active session on iOS and Android. Successful verification refreshes the JavaScript session token without creating a new session. +Add `useBiometricCredentials().reverify()` for first-, second-, and multi-factor verification of the active session on iOS and Android. If the first factor still requires a second factor, reverification continues the same attempt automatically. Successful verification refreshes the JavaScript session token without creating a new session. New biometric enrollments default to `biometry_current_set`, requiring biometrics without device-passcode fallback. Existing credentials keep their original policy; Android reverification requires the stronger policy and returns `biometric_credential_policy_incompatible` for older, weaker credentials. Apps should offer another verification method in that case. Reverification requires an updated native development build. diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 718e5b16e19..c329174372d 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -31,6 +31,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -123,6 +125,26 @@ internal fun biometricReverificationPayload( "sessionId" to (verification.session?.id ?: sessionId) ) +internal suspend fun verifyBiometricReverification( + started: SessionVerification, + verify: suspend (SessionVerification.Level) -> ClerkResult +): ClerkResult = when (started.status) { + SessionVerification.Status.NEEDS_FIRST_FACTOR -> { + val result = verify(SessionVerification.Level.FIRST_FACTOR) + if (result is ClerkResult.Success && result.value.status == SessionVerification.Status.NEEDS_SECOND_FACTOR) { + currentCoroutineContext().ensureActive() + verify(SessionVerification.Level.SECOND_FACTOR) + } else { + result + } + } + SessionVerification.Status.NEEDS_SECOND_FACTOR -> verify(SessionVerification.Level.SECOND_FACTOR) + SessionVerification.Status.COMPLETE -> ClerkResult.success(started) + SessionVerification.Status.UNKNOWN -> ClerkResult.unknownFailure( + IllegalStateException("The server returned an unsupported reverification status.") + ) +} + internal data class BiometricCredentialBridgeError( val code: String, val message: String @@ -816,20 +838,10 @@ class ClerkExpoModule : Module() { return@launch } } - val factor = when (started.status) { - SessionVerification.Status.NEEDS_FIRST_FACTOR -> SessionVerification.Level.FIRST_FACTOR - SessionVerification.Status.NEEDS_SECOND_FACTOR -> SessionVerification.Level.SECOND_FACTOR - SessionVerification.Status.COMPLETE -> { - promise.resolve(biometricReverificationPayload(started, sessionId)) - return@launch - } - SessionVerification.Status.UNKNOWN -> { - promise.reject("E_BIOMETRIC_REVERIFICATION_FAILED", - "The server returned an unsupported reverification status.", null) - return@launch - } + val result = verifyBiometricReverification(started) { factor -> + session.verifyWithBiometrics(promptSubtitle = reason, level = factor) } - when (val result = session.verifyWithBiometrics(promptSubtitle = reason, level = factor)) { + when (result) { is ClerkResult.Success -> promise.resolve(biometricReverificationPayload(result.value, sessionId)) is ClerkResult.Failure -> rejectBiometricCredentialFailure( promise, "E_BIOMETRIC_REVERIFICATION_FAILED", "Unable to reverify with biometrics", result diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt b/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt index 886fd737c28..525651e5510 100644 --- a/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt +++ b/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt @@ -9,11 +9,113 @@ import com.clerk.api.biometriccredential.BiometricCredentialAvailability import com.clerk.api.biometriccredential.BiometricCredentialKeyManagerException import com.clerk.api.biometriccredential.BiometricCredentialPolicy import com.clerk.api.session.SessionVerification +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test class BiometricCredentialBridgeTest { + @Test + fun `continues the same attempt when the first factor requires a second factor`() = runBlocking { + val factors = mutableListOf() + val started = SessionVerification(id = "stepup_test", status = SessionVerification.Status.NEEDS_FIRST_FACTOR, + level = SessionVerification.Level.MULTI_FACTOR) + val result = verifyBiometricReverification(started) { factor -> + factors.add(factor) + ClerkResult.success(started.copy(status = if (factor == SessionVerification.Level.FIRST_FACTOR) + SessionVerification.Status.NEEDS_SECOND_FACTOR else SessionVerification.Status.COMPLETE)) + } + assertEquals(listOf(SessionVerification.Level.FIRST_FACTOR, SessionVerification.Level.SECOND_FACTOR), factors) + assertTrue(result is ClerkResult.Success) + assertEquals(started.id, (result as ClerkResult.Success).value.id) + assertEquals(SessionVerification.Status.COMPLETE, result.value.status) + } + + @Test + fun `prompts only for the factors still required`() = runBlocking { + for (status in listOf(SessionVerification.Status.NEEDS_FIRST_FACTOR, + SessionVerification.Status.NEEDS_SECOND_FACTOR, SessionVerification.Status.COMPLETE)) { + val factors = mutableListOf() + val started = SessionVerification(status = status, level = SessionVerification.Level.MULTI_FACTOR) + val result = verifyBiometricReverification(started) { factor -> + factors.add(factor) + ClerkResult.success(started.copy(status = SessionVerification.Status.COMPLETE)) + } + val expected = when (status) { + SessionVerification.Status.NEEDS_FIRST_FACTOR -> listOf(SessionVerification.Level.FIRST_FACTOR) + SessionVerification.Status.NEEDS_SECOND_FACTOR -> listOf(SessionVerification.Level.SECOND_FACTOR) + else -> emptyList() + } + assertEquals(expected, factors) + assertEquals(SessionVerification.Status.COMPLETE, (result as ClerkResult.Success).value.status) + } + } + + @Test + fun `does not retry an incomplete second factor`() = runBlocking { + val factors = mutableListOf() + val started = SessionVerification(status = SessionVerification.Status.NEEDS_FIRST_FACTOR, + level = SessionVerification.Level.MULTI_FACTOR) + val result = verifyBiometricReverification(started) { factor -> + factors.add(factor) + ClerkResult.success(started.copy(status = SessionVerification.Status.NEEDS_SECOND_FACTOR)) + } + assertEquals(listOf(SessionVerification.Level.FIRST_FACTOR, SessionVerification.Level.SECOND_FACTOR), factors) + assertEquals(SessionVerification.Status.NEEDS_SECOND_FACTOR, (result as ClerkResult.Success).value.status) + } + + @Test + fun `preserves first and second factor failures`() = runBlocking { + for (failingFactor in listOf(SessionVerification.Level.FIRST_FACTOR, SessionVerification.Level.SECOND_FACTOR)) { + val factors = mutableListOf() + val failure = ClerkResult.apiFailure(ClerkErrorResponse(errors = listOf( + ClerkAPIError(code = "biometric_authentication_canceled", message = "Canceled") + ))) + val started = SessionVerification(status = SessionVerification.Status.NEEDS_FIRST_FACTOR, + level = SessionVerification.Level.MULTI_FACTOR) + val result = verifyBiometricReverification(started) { factor -> + factors.add(factor) + if (factor == failingFactor) failure else + ClerkResult.success(started.copy(status = SessionVerification.Status.NEEDS_SECOND_FACTOR)) + } + assertSame(failure, result) + assertEquals(if (failingFactor == SessionVerification.Level.FIRST_FACTOR) + listOf(SessionVerification.Level.FIRST_FACTOR) else + listOf(SessionVerification.Level.FIRST_FACTOR, SessionVerification.Level.SECOND_FACTOR), factors) + } + } + + @Test + fun `cancellation between factors stops continuation`() = runBlocking { + val factors = mutableListOf() + val job = launch { + verifyBiometricReverification(SessionVerification(status = SessionVerification.Status.NEEDS_FIRST_FACTOR, + level = SessionVerification.Level.MULTI_FACTOR)) { factor -> + factors.add(factor) + currentCoroutineContext().cancel() + ClerkResult.success(SessionVerification(status = SessionVerification.Status.NEEDS_SECOND_FACTOR, + level = SessionVerification.Level.MULTI_FACTOR)) + } + } + job.join() + assertTrue(job.isCancelled) + assertEquals(listOf(SessionVerification.Level.FIRST_FACTOR), factors) + } + + @Test + fun `unknown verification status does not prompt`() = runBlocking { + val result = verifyBiometricReverification(SessionVerification(status = SessionVerification.Status.UNKNOWN, + level = SessionVerification.Level.MULTI_FACTOR)) { + throw AssertionError("Unknown verification status must not prompt") + } + assertTrue(result is ClerkResult.Failure) + } + @Test fun `maps biometric reverification requirements`() { assertEquals(SessionVerification.Level.FIRST_FACTOR, biometricReverificationLevel("first_factor")) diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 873db5c9878..b6bf9c89af7 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -845,21 +845,35 @@ final class ClerkNativeBridge { } let started = try await session.startVerification(level: requestedLevel) - let verification: SessionVerification + let verification = try await Self.verifyBiometricReverification(started) { level in + try await session.verifyWithBiometrics(reason: reason, level: level) + } + return Self.biometricReverificationPayload(verification, sessionId: sessionId) + } + + @MainActor + static func verifyBiometricReverification( + _ started: SessionVerification, + verify: (Session.BiometricVerificationLevel) async throws -> SessionVerification + ) async throws -> SessionVerification { switch started.status { case .needsFirstFactor: - verification = try await session.verifyWithBiometrics(reason: reason, level: .firstFactor) + let verification = try await verify(.firstFactor) + if verification.status == .needsSecondFactor { + try Task.checkCancellation() + return try await verify(.secondFactor) + } + return verification case .needsSecondFactor: - verification = try await session.verifyWithBiometrics(reason: reason, level: .secondFactor) + return try await verify(.secondFactor) case .complete: - verification = started + return started case .unknown: throw ClerkExpoBiometricCredentialError( code: "E_BIOMETRIC_REVERIFICATION_FAILED", message: "The server returned an unsupported reverification status." ) } - return Self.biometricReverificationPayload(verification, sessionId: sessionId) } static func biometricReverificationLevel(_ level: String) throws -> SessionVerification.Level { @@ -918,6 +932,11 @@ final class ClerkNativeBridge { return ClerkNativeErrorDescriptor(code: error.code, message: error.localizedDescription) } + let nsError = error as NSError + if nsError.domain == "ClerkKit.BiometricCredentialError", let code = nsError.userInfo["code"] as? String { + return ClerkNativeErrorDescriptor(code: code, message: error.localizedDescription) + } + if let error = error as? BiometricCredentialKeyManagerError { return ClerkNativeErrorDescriptor( code: biometricCredentialKeyManagerErrorCode(error), diff --git a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift b/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift index 03bec9e7536..b19f9e15be4 100644 --- a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift +++ b/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift @@ -60,6 +60,114 @@ final class ClerkNativeBridgeTests: XCTestCase { XCTAssertEqual(payload["sessionId"] as? String, "sess_test") } + @MainActor + func testBiometricReverificationContinuesToSecondFactor() async throws { + var factors: [Session.BiometricVerificationLevel] = [] + let started = SessionVerification(id: "stepup_test", status: .needsFirstFactor, level: .multiFactor) + let result = try await ClerkNativeBridge.verifyBiometricReverification(started) { factor in + factors.append(factor) + return SessionVerification(id: started.id, status: factor == .firstFactor ? .needsSecondFactor : .complete, + level: .multiFactor) + } + XCTAssertEqual(factors, [.firstFactor, .secondFactor]) + XCTAssertEqual(result.id, started.id) + XCTAssertEqual(result.status, .complete) + } + + @MainActor + func testBiometricReverificationPromptsOnlyForRequiredFactors() async throws { + for status in [SessionVerification.Status.needsFirstFactor, .needsSecondFactor, .complete] { + var factors: [Session.BiometricVerificationLevel] = [] + let started = SessionVerification(id: "stepup_test", status: status, level: .multiFactor) + let result = try await ClerkNativeBridge.verifyBiometricReverification(started) { factor in + factors.append(factor) + return SessionVerification(id: started.id, status: .complete, level: .multiFactor) + } + XCTAssertEqual(factors, status == .complete ? [] : [status == .needsFirstFactor ? .firstFactor : .secondFactor]) + XCTAssertEqual(result.status, .complete) + } + } + + @MainActor + func testBiometricReverificationDoesNotRetryAnIncompleteSecondFactor() async throws { + var factors: [Session.BiometricVerificationLevel] = [] + let started = SessionVerification(status: .needsFirstFactor, level: .multiFactor) + let result = try await ClerkNativeBridge.verifyBiometricReverification(started) { factor in + factors.append(factor) + return SessionVerification(status: .needsSecondFactor, level: .multiFactor) + } + XCTAssertEqual(factors, [.firstFactor, .secondFactor]) + XCTAssertEqual(result.status, .needsSecondFactor) + } + + @MainActor + func testBiometricReverificationPreservesFactorFailures() async { + for failingFactor in [Session.BiometricVerificationLevel.firstFactor, .secondFactor] { + var factors: [Session.BiometricVerificationLevel] = [] + let expected = NSError(domain: "biometric_authentication_canceled", code: 1) + do { + _ = try await ClerkNativeBridge.verifyBiometricReverification( + SessionVerification(status: .needsFirstFactor, level: .multiFactor) + ) { factor in + factors.append(factor) + if factor == failingFactor { throw expected } + return SessionVerification(status: .needsSecondFactor, level: .multiFactor) + } + XCTFail("Expected the factor failure to propagate.") + } catch { + XCTAssertEqual(error as NSError, expected) + } + XCTAssertEqual(factors, failingFactor == .firstFactor ? [.firstFactor] : [.firstFactor, .secondFactor]) + } + } + + @MainActor + func testCancellationBetweenBiometricFactorsStopsContinuation() async { + var factors: [Session.BiometricVerificationLevel] = [] + let task = Task { @MainActor in + try await ClerkNativeBridge.verifyBiometricReverification( + SessionVerification(status: .needsFirstFactor, level: .multiFactor) + ) { factor in + factors.append(factor) + withUnsafeCurrentTask { $0?.cancel() } + return SessionVerification(status: .needsSecondFactor, level: .multiFactor) + } + } + do { + _ = try await task.value + XCTFail("Expected cancellation to stop the second factor.") + } catch { + XCTAssertTrue(error is CancellationError) + } + XCTAssertEqual(factors, [.firstFactor]) + } + + @MainActor + func testUnknownBiometricVerificationStatusDoesNotPrompt() async { + do { + _ = try await ClerkNativeBridge.verifyBiometricReverification( + SessionVerification(status: .unknown("future_status"), level: .multiFactor) + ) { _ in + XCTFail("An unknown status must not prompt.") + return SessionVerification(status: .complete, level: .multiFactor) + } + XCTFail("Expected an unsupported status error.") + } catch { + XCTAssertEqual(ClerkNativeBridge.biometricCredentialErrorDescriptor(error, fallbackCode: "unexpected").code, + "E_BIOMETRIC_REVERIFICATION_FAILED") + } + } + + func testBiometricReverificationPreservesNativePolicyError() { + let error = NSError(domain: "ClerkKit.BiometricCredentialError", code: 1, userInfo: [ + "code": "biometric_credential_policy_incompatible", + NSLocalizedDescriptionKey: "Verify your identity using another method.", + ]) + let result = ClerkNativeBridge.biometricCredentialErrorDescriptor(error, fallbackCode: "unexpected") + XCTAssertEqual(result.code, "biometric_credential_policy_incompatible") + XCTAssertEqual(result.message, error.localizedDescription) + } + @MainActor private func assertEnvironmentUnavailable( _ operation: @MainActor () async throws -> Any, From 7f24730cf244f8614dfe2e97551b33f67f2e008d Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 23 Sep 2026 11:17:54 -0400 Subject: [PATCH 4/4] fix(expo): use released iOS biometric policy enforcement --- .changeset/biometric-session-reverification.md | 2 +- packages/expo/ios/ClerkNativeBridge.swift | 5 ++--- packages/expo/ios/Tests/ClerkNativeBridgeTests.swift | 5 +---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.changeset/biometric-session-reverification.md b/.changeset/biometric-session-reverification.md index ac15ce90fe8..c3cde8fa17e 100644 --- a/.changeset/biometric-session-reverification.md +++ b/.changeset/biometric-session-reverification.md @@ -4,4 +4,4 @@ Add `useBiometricCredentials().reverify()` for first-, second-, and multi-factor verification of the active session on iOS and Android. If the first factor still requires a second factor, reverification continues the same attempt automatically. Successful verification refreshes the JavaScript session token without creating a new session. -New biometric enrollments default to `biometry_current_set`, requiring biometrics without device-passcode fallback. Existing credentials keep their original policy; Android reverification requires the stronger policy and returns `biometric_credential_policy_incompatible` for older, weaker credentials. Apps should offer another verification method in that case. Reverification requires an updated native development build. +New biometric enrollments default to `biometry_current_set`, requiring biometrics without device-passcode fallback. Existing credentials keep their original policy; reverification on iOS and Android requires the stronger policy and returns `biometric_credential_policy_incompatible` for older, weaker credentials. Apps should offer another verification method in that case. Reverification requires an updated native development build. diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index b6bf9c89af7..bcb20cb8bfc 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -932,9 +932,8 @@ final class ClerkNativeBridge { return ClerkNativeErrorDescriptor(code: error.code, message: error.localizedDescription) } - let nsError = error as NSError - if nsError.domain == "ClerkKit.BiometricCredentialError", let code = nsError.userInfo["code"] as? String { - return ClerkNativeErrorDescriptor(code: code, message: error.localizedDescription) + if let error = error as? BiometricCredentialError { + return ClerkNativeErrorDescriptor(code: error.rawValue, message: error.localizedDescription) } if let error = error as? BiometricCredentialKeyManagerError { diff --git a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift b/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift index b19f9e15be4..ab9fe8b6862 100644 --- a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift +++ b/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift @@ -159,10 +159,7 @@ final class ClerkNativeBridgeTests: XCTestCase { } func testBiometricReverificationPreservesNativePolicyError() { - let error = NSError(domain: "ClerkKit.BiometricCredentialError", code: 1, userInfo: [ - "code": "biometric_credential_policy_incompatible", - NSLocalizedDescriptionKey: "Verify your identity using another method.", - ]) + let error = BiometricCredentialError.policyIncompatible let result = ClerkNativeBridge.biometricCredentialErrorDescriptor(error, fallbackCode: "unexpected") XCTAssertEqual(result.code, "biometric_credential_policy_incompatible") XCTAssertEqual(result.message, error.localizedDescription)