From c6c3b542b936d55a96043355e5edf82de971270c Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 27 Jul 2026 14:17:30 -0400 Subject: [PATCH 01/10] feat(expo): add biometric trusted devices --- .changeset/thin-spoons-trust.md | 5 + packages/expo/README.md | 41 +++ packages/expo/android/build.gradle | 2 + .../expo/modules/clerk/ClerkExpoModule.kt | 341 +++++++++++++++++- .../modules/clerk/TrustedDeviceBridgeTest.kt | 126 +++++++ packages/expo/app.plugin.js | 19 + packages/expo/ios/ClerkExpoModule.swift | 182 +++++++++- packages/expo/ios/ClerkNativeBridge.swift | 273 ++++++++++++++ .../src/__tests__/appPlugin.theme.test.js | 50 ++- packages/expo/src/index.ts | 1 + packages/expo/src/native/AuthView.tsx | 17 +- packages/expo/src/native/AuthView.types.ts | 4 +- .../src/native/__tests__/useAuthFlow.test.tsx | 91 +++++ packages/expo/src/native/index.ts | 2 + packages/expo/src/native/useAuthFlow.ts | 112 ++++++ .../src/specs/NativeClerkModule.android.ts | 4 +- packages/expo/src/specs/NativeClerkModule.ts | 4 +- .../expo/src/specs/NativeClerkModule.types.ts | 45 +++ .../__tests__/useTrustedDevices.test.ts | 232 ++++++++++++ packages/expo/src/trusted-devices/errors.ts | 28 ++ packages/expo/src/trusted-devices/index.ts | 3 + packages/expo/src/trusted-devices/types.ts | 69 ++++ .../useTrustedDevices.android.ts | 1 + .../trusted-devices/useTrustedDevices.ios.ts | 1 + .../useTrustedDevices.shared.ts | 73 ++++ .../src/trusted-devices/useTrustedDevices.ts | 30 ++ packages/expo/src/utils/native-module.ts | 5 +- 27 files changed, 1742 insertions(+), 19 deletions(-) create mode 100644 .changeset/thin-spoons-trust.md create mode 100644 packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt create mode 100644 packages/expo/src/native/__tests__/useAuthFlow.test.tsx create mode 100644 packages/expo/src/native/useAuthFlow.ts create mode 100644 packages/expo/src/specs/NativeClerkModule.types.ts create mode 100644 packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts create mode 100644 packages/expo/src/trusted-devices/errors.ts create mode 100644 packages/expo/src/trusted-devices/index.ts create mode 100644 packages/expo/src/trusted-devices/types.ts create mode 100644 packages/expo/src/trusted-devices/useTrustedDevices.android.ts create mode 100644 packages/expo/src/trusted-devices/useTrustedDevices.ios.ts create mode 100644 packages/expo/src/trusted-devices/useTrustedDevices.shared.ts create mode 100644 packages/expo/src/trusted-devices/useTrustedDevices.ts diff --git a/.changeset/thin-spoons-trust.md b/.changeset/thin-spoons-trust.md new file mode 100644 index 00000000000..6f1cae618aa --- /dev/null +++ b/.changeset/thin-spoons-trust.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': minor +--- + +Add iOS and Android APIs for biometric trusted-device enrollment, sign-in, availability, listing, and revocation, including structured native error codes and forward-compatible resource values. Add native authentication-flow readiness state for safely gating authenticated content and support configuring the Face ID permission message through the Expo config plugin. diff --git a/packages/expo/README.md b/packages/expo/README.md index 1fa4fbb447f..3bd26b16dd5 100644 --- a/packages/expo/README.md +++ b/packages/expo/README.md @@ -48,6 +48,47 @@ 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 trusted devices + +Biometric trusted-device 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. + +Trusted-device operations preserve Clerk API and native biometric error codes. Use `isTrustedDeviceError(error)` to safely inspect `error.code`; unrecognized codes and resource values remain available for forward compatibility. + +#### Face ID on iOS + +Apps that use Face ID for trusted-device enrollment or sign-in 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). diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle index a05ecda649c..eac2d2074c5 100644 --- a/packages/expo/android/build.gradle +++ b/packages/expo/android/build.gradle @@ -144,4 +144,6 @@ dependencies { implementation "androidx.activity:activity-compose:$activityComposeVersion" implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion" + + testImplementation "junit:junit:4.13.2" } 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 326d9b7d8f2..27f51558239 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 @@ -10,8 +10,14 @@ import com.clerk.api.Clerk import com.clerk.api.ClerkConfigurationOptions import com.clerk.api.FrameworkIntegrationApi import com.clerk.api.network.model.client.Client +import com.clerk.api.network.model.error.ClerkErrorResponse import com.clerk.api.network.model.error.firstMessage import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.signin.SignIn +import com.clerk.api.trusteddevice.TrustedDevice +import com.clerk.api.trusteddevice.TrustedDeviceAvailability +import com.clerk.api.trusteddevice.TrustedDeviceKeyManagerException +import com.clerk.api.trusteddevice.TrustedDevicePolicy import com.clerk.api.ui.ClerkColors import com.clerk.api.ui.ClerkDesign import com.clerk.api.ui.ClerkTheme @@ -22,12 +28,15 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.json.JSONObject private const val TAG = "ClerkExpoModule" +private const val NATIVE_AUTH_FLOW_CHANGED_EVENT = "clerkNativeAuthFlowChanged" private const val NATIVE_CLIENT_CHANGED_EVENT = "clerkNativeClientChanged" private const val HOST_SDK_HEADER = "x-clerk-host-sdk" private const val HOST_SDK_VERSION_HEADER = "x-clerk-host-sdk-version" @@ -39,13 +48,101 @@ private fun debugLog(tag: String, message: String) { } } +internal fun trustedDeviceAvailabilityPayload( + availability: TrustedDeviceAvailability +): Map { + return mapOf( + "isAvailable" to availability.isAvailable, + "unavailableReason" to availability.unavailableReason?.name?.lowercase() + ) +} + +internal fun trustedDevicePayload(trustedDevice: TrustedDevice): Map { + return mapOf( + "id" to trustedDevice.id, + "object" to "trusted_device", + "platform" to trustedDevice.platform.name.lowercase(), + "appIdentifier" to trustedDevice.appIdentifier, + "name" to trustedDevice.name, + "algorithm" to trustedDevice.algorithm, + "status" to trustedDevice.status.name.lowercase(), + "createdAt" to trustedDevice.createdAt, + "updatedAt" to trustedDevice.updatedAt, + "lastUsedAt" to trustedDevice.lastUsedAt, + "revokedAt" to trustedDevice.revokedAt + ) +} + +internal fun trustedDeviceSignInPayload(signIn: SignIn): Map { + return mapOf( + "status" to signIn.status.name.lowercase(), + "createdSessionId" to signIn.createdSessionId + ) +} + +internal fun trustedDevicePolicy(policy: String): TrustedDevicePolicy? { + return when (policy) { + "biometry_current_set" -> TrustedDevicePolicy.BIOMETRY_CURRENT_SET + "biometry_any" -> TrustedDevicePolicy.BIOMETRY_ANY + "biometry_or_device_passcode" -> TrustedDevicePolicy.BIOMETRY_OR_DEVICE_PASSCODE + else -> null + } +} + +internal data class TrustedDeviceBridgeError( + val code: String, + val message: String +) + +internal fun trustedDeviceKeyManagerErrorCode( + code: TrustedDeviceKeyManagerException.Code +): String = code.name.lowercase() + +internal fun trustedDeviceBridgeError( + throwable: Throwable, + fallbackCode: String, + fallbackMessage: String +): TrustedDeviceBridgeError { + val keyManagerError = throwable as? TrustedDeviceKeyManagerException + return TrustedDeviceBridgeError( + code = keyManagerError?.code?.let(::trustedDeviceKeyManagerErrorCode) ?: fallbackCode, + message = throwable.message ?: fallbackMessage + ) +} + +internal fun trustedDeviceBridgeError( + failure: ClerkResult.Failure, + fallbackCode: String, + fallbackMessage: String +): TrustedDeviceBridgeError { + val apiError = failure.error?.errors?.firstOrNull() + val throwable = failure.throwable + val keyManagerError = throwable as? TrustedDeviceKeyManagerException + + return TrustedDeviceBridgeError( + code = apiError?.code + ?: keyManagerError?.code?.let(::trustedDeviceKeyManagerErrorCode) + ?: fallbackCode, + message = apiError?.longMessage + ?: apiError?.message + ?: throwable?.message + ?: fallbackMessage + ) +} + class ClerkExpoModule : Module() { private val coroutineScope = CoroutineScope(Dispatchers.Main) + private var authFlowStateObserverJob: Job? = null private var clientStateObserverJob: Job? = null private var lastObservedClientState: ClientStateSnapshot? = null private var jsOriginatedClientSyncDepth = 0 private var configuredPublishableKey: String? = null + private data class AuthFlowStateSnapshot( + val isLoaded: Boolean, + val isAuthFlowComplete: Boolean + ) + private data class ClientStateSnapshot( val client: Client?, val deviceToken: String? @@ -74,16 +171,19 @@ class ClerkExpoModule : Module() { override fun definition() = ModuleDefinition { Name("ClerkExpo") - Events(NATIVE_CLIENT_CHANGED_EVENT) + Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT) OnCreate { sharedInstance = this@ClerkExpoModule + startAuthFlowStateObserver() } OnDestroy { if (sharedInstance === this@ClerkExpoModule) { sharedInstance = null } + authFlowStateObserverJob?.cancel() + authFlowStateObserverJob = null clientStateObserverJob?.cancel() clientStateObserverJob = null } @@ -96,6 +196,10 @@ class ClerkExpoModule : Module() { getClientToken(promise) } + AsyncFunction("getAuthFlowState") { promise: Promise -> + promise.resolve(authFlowStatePayload()) + } + AsyncFunction("syncClientStateFromJs") { deviceToken: String?, sourceId: String?, @@ -110,6 +214,38 @@ class ClerkExpoModule : Module() { promise ) } + + AsyncFunction("getTrustedDeviceAvailability") { + id: String?, + identifierHint: String?, + promise: Promise -> + getTrustedDeviceAvailability(id, identifierHint, promise) + } + + AsyncFunction("listTrustedDevices") { promise: Promise -> + listTrustedDevices(promise) + } + + AsyncFunction("enrollTrustedDevice") { + deviceName: String?, + identifierHint: String?, + reason: String?, + policy: String, + promise: Promise -> + enrollTrustedDevice(deviceName, identifierHint, reason, policy, promise) + } + + AsyncFunction("revokeTrustedDevice") { id: String, promise: Promise -> + revokeTrustedDevice(id, promise) + } + + AsyncFunction("signInWithTrustedDevice") { + id: String?, + identifierHint: String?, + reason: String?, + promise: Promise -> + signInWithTrustedDevice(id, identifierHint, reason, promise) + } } private val reactContext: Context? @@ -130,6 +266,37 @@ class ClerkExpoModule : Module() { .withCustomHeaders(customHeaders) } + private fun startAuthFlowStateObserver() { + if (authFlowStateObserverJob != null) { + return + } + + authFlowStateObserverJob = coroutineScope.launch { + combine(Clerk.isInitialized, Clerk.isAuthFlowCompleteFlow) { isLoaded, isAuthFlowComplete -> + AuthFlowStateSnapshot( + isLoaded = isLoaded, + isAuthFlowComplete = isLoaded && isAuthFlowComplete + ) + } + .distinctUntilChanged() + .collect { state -> + sendEvent(NATIVE_AUTH_FLOW_CHANGED_EVENT, authFlowStatePayload(state)) + } + } + } + + private fun authFlowStatePayload( + state: AuthFlowStateSnapshot = AuthFlowStateSnapshot( + isLoaded = Clerk.isInitialized.value, + isAuthFlowComplete = Clerk.isInitialized.value && Clerk.isAuthFlowComplete + ) + ): Map { + return mapOf( + "isLoaded" to state.isLoaded, + "isAuthFlowComplete" to state.isAuthFlowComplete + ) + } + private fun startClientStateObserver() { if (clientStateObserverJob != null) { return @@ -388,6 +555,178 @@ class ClerkExpoModule : Module() { } } + // MARK: - trusted devices + + private fun getTrustedDeviceAvailability( + id: String?, + identifierHint: String?, + promise: Promise + ) { + coroutineScope.launch { + try { + val availability = Clerk.trustedDevices.availability(id, identifierHint) + promise.resolve(trustedDeviceAvailabilityPayload(availability)) + } catch (e: Exception) { + rejectTrustedDeviceException( + promise = promise, + fallbackCode = "E_TRUSTED_DEVICE_AVAILABILITY_FAILED", + fallbackMessage = "Unable to determine trusted-device availability", + exception = e + ) + } + } + } + + private fun listTrustedDevices(promise: Promise) { + coroutineScope.launch { + try { + when (val result = Clerk.trustedDevices.list()) { + is ClerkResult.Success -> promise.resolve(result.value.map(::trustedDevicePayload)) + is ClerkResult.Failure -> rejectTrustedDeviceFailure( + promise, + "E_TRUSTED_DEVICE_LIST_FAILED", + "Unable to list trusted devices", + result + ) + } + } catch (e: Exception) { + rejectTrustedDeviceException( + promise = promise, + fallbackCode = "E_TRUSTED_DEVICE_LIST_FAILED", + fallbackMessage = "Unable to list trusted devices", + exception = e + ) + } + } + } + + private fun enrollTrustedDevice( + deviceName: String?, + identifierHint: String?, + reason: String?, + policy: String, + promise: Promise + ) { + val trustedDevicePolicy = trustedDevicePolicy(policy) + if (trustedDevicePolicy == null) { + promise.reject( + "invalid_trusted_device_policy", + "Invalid trusted-device policy: $policy", + null + ) + return + } + + coroutineScope.launch { + try { + when ( + val result = Clerk.trustedDevices.enroll( + deviceName = deviceName, + identifierHint = identifierHint, + policy = trustedDevicePolicy, + promptSubtitle = reason + ) + ) { + is ClerkResult.Success -> promise.resolve(trustedDevicePayload(result.value)) + is ClerkResult.Failure -> rejectTrustedDeviceFailure( + promise, + "E_TRUSTED_DEVICE_ENROLLMENT_FAILED", + "Unable to enroll trusted device", + result + ) + } + } catch (e: Exception) { + rejectTrustedDeviceException( + promise = promise, + fallbackCode = "E_TRUSTED_DEVICE_ENROLLMENT_FAILED", + fallbackMessage = "Unable to enroll trusted device", + exception = e + ) + } + } + } + + private fun revokeTrustedDevice(id: String, promise: Promise) { + coroutineScope.launch { + try { + when (val result = Clerk.trustedDevices.revoke(id)) { + is ClerkResult.Success -> promise.resolve(trustedDevicePayload(result.value)) + is ClerkResult.Failure -> rejectTrustedDeviceFailure( + promise, + "E_TRUSTED_DEVICE_REVOCATION_FAILED", + "Unable to revoke trusted device", + result + ) + } + } catch (e: Exception) { + rejectTrustedDeviceException( + promise = promise, + fallbackCode = "E_TRUSTED_DEVICE_REVOCATION_FAILED", + fallbackMessage = "Unable to revoke trusted device", + exception = e + ) + } + } + } + + private fun signInWithTrustedDevice( + id: String?, + identifierHint: String?, + reason: String?, + promise: Promise + ) { + coroutineScope.launch { + try { + when ( + val result = Clerk.trustedDevices.signIn( + id = id, + identifierHint = identifierHint, + promptSubtitle = reason + ) + ) { + is ClerkResult.Success -> promise.resolve(trustedDeviceSignInPayload(result.value)) + is ClerkResult.Failure -> rejectTrustedDeviceFailure( + promise, + "E_TRUSTED_DEVICE_SIGN_IN_FAILED", + "Unable to sign in with trusted device", + result + ) + } + } catch (e: Exception) { + rejectTrustedDeviceException( + promise = promise, + fallbackCode = "E_TRUSTED_DEVICE_SIGN_IN_FAILED", + fallbackMessage = "Unable to sign in with trusted device", + exception = e + ) + } + } + } + + private fun rejectTrustedDeviceFailure( + promise: Promise, + code: String, + fallbackMessage: String, + failure: ClerkResult.Failure + ) { + val error = trustedDeviceBridgeError(failure, code, fallbackMessage) + promise.reject( + error.code, + error.message, + failure.throwable + ) + } + + private fun rejectTrustedDeviceException( + promise: Promise, + fallbackCode: String, + fallbackMessage: String, + exception: Exception + ) { + val error = trustedDeviceBridgeError(exception, fallbackCode, fallbackMessage) + promise.reject(error.code, error.message, exception) + } + // MARK: - syncClientStateFromJs private fun syncClientStateFromJs( diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt b/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt new file mode 100644 index 00000000000..1e69471e236 --- /dev/null +++ b/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt @@ -0,0 +1,126 @@ +package expo.modules.clerk + +import com.clerk.api.network.model.error.ClerkErrorResponse +import com.clerk.api.network.model.error.Error as ClerkAPIError +import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.signin.SignIn +import com.clerk.api.trusteddevice.TrustedDevice +import com.clerk.api.trusteddevice.TrustedDeviceAvailability +import com.clerk.api.trusteddevice.TrustedDeviceKeyManagerException +import com.clerk.api.trusteddevice.TrustedDevicePolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TrustedDeviceBridgeTest { + @Test + fun `maps trusted-device availability to the JavaScript contract`() { + assertEquals( + mapOf("isAvailable" to true, "unavailableReason" to null), + trustedDeviceAvailabilityPayload(TrustedDeviceAvailability.Available) + ) + assertEquals( + mapOf( + "isAvailable" to false, + "unavailableReason" to "biometric_authentication_unavailable" + ), + trustedDeviceAvailabilityPayload( + TrustedDeviceAvailability.Unavailable( + TrustedDeviceAvailability.UnavailableReason.BIOMETRIC_AUTHENTICATION_UNAVAILABLE + ) + ) + ) + } + + @Test + fun `maps trusted-device resources to the JavaScript contract`() { + val payload = trustedDevicePayload( + TrustedDevice( + id = "td_123", + platform = TrustedDevice.Platform.ANDROID, + appIdentifier = "com.example.app", + name = "Pixel", + status = TrustedDevice.Status.ACTIVE, + createdAt = 1_700_000_000_000, + updatedAt = 1_700_000_100_000, + lastUsedAt = 1_700_000_200_000 + ) + ) + + assertEquals("trusted_device", payload["object"]) + assertEquals("android", payload["platform"]) + assertEquals("active", payload["status"]) + assertEquals("ES256", payload["algorithm"]) + assertEquals(1_700_000_200_000, payload["lastUsedAt"]) + assertNull(payload["revokedAt"]) + } + + @Test + fun `maps every supported authentication policy`() { + assertEquals( + TrustedDevicePolicy.BIOMETRY_CURRENT_SET, + trustedDevicePolicy("biometry_current_set") + ) + assertEquals(TrustedDevicePolicy.BIOMETRY_ANY, trustedDevicePolicy("biometry_any")) + assertEquals( + TrustedDevicePolicy.BIOMETRY_OR_DEVICE_PASSCODE, + trustedDevicePolicy("biometry_or_device_passcode") + ) + assertNull(trustedDevicePolicy("unsupported")) + } + + @Test + fun `maps trusted-device sign-in results`() { + assertEquals( + mapOf("status" to "complete", "createdSessionId" to "sess_123"), + trustedDeviceSignInPayload( + SignIn( + id = "sia_123", + status = SignIn.Status.COMPLETE, + createdSessionId = "sess_123" + ) + ) + ) + } + + @Test + fun `preserves Clerk API error codes and detailed messages`() { + val failure = ClerkResult.apiFailure( + ClerkErrorResponse( + errors = listOf( + ClerkAPIError( + code = "trusted_device_not_registered", + message = "Trusted device not found.", + longMessage = "This device is no longer registered as trusted." + ) + ) + ) + ) + + assertEquals( + TrustedDeviceBridgeError( + code = "trusted_device_not_registered", + message = "This device is no longer registered as trusted." + ), + trustedDeviceBridgeError( + failure = failure, + fallbackCode = "E_TRUSTED_DEVICE_SIGN_IN_FAILED", + fallbackMessage = "Unable to sign in with trusted device" + ) + ) + } + + @Test + fun `normalizes native key-manager error codes`() { + assertEquals( + "biometric_authentication_canceled", + trustedDeviceKeyManagerErrorCode( + TrustedDeviceKeyManagerException.Code.BIOMETRIC_AUTHENTICATION_CANCELED + ) + ) + assertEquals( + "key_invalidated", + trustedDeviceKeyManagerErrorCode(TrustedDeviceKeyManagerException.Code.KEY_INVALIDATED) + ) + } +} diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 358fb09abec..f8483877ff3 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -212,6 +212,23 @@ const withClerkKeychainService = (config, { keychainService } = {}) => { }); }; +const withClerkFaceIDPermission = (config, { faceIDPermission } = {}) => { + if (faceIDPermission === undefined) { + return config; + } + + if (typeof faceIDPermission !== 'string' || faceIDPermission.trim().length === 0) { + throw new Error('Clerk: faceIDPermission must be a non-empty string'); + } + + return withInfoPlist(config, modConfig => { + if (!Object.hasOwn(modConfig.modResults, 'NSFaceIDUsageDescription')) { + modConfig.modResults.NSFaceIDUsageDescription = faceIDPermission; + } + return modConfig; + }); +}; + /** * Add Sign in with Apple entitlement to the iOS app. * Required for the native Apple Sign In flow via ASAuthorizationController. @@ -354,6 +371,7 @@ const withClerkExpo = (config, props = {}) => { } config = withClerkAndroid(config); config = withClerkKeychainService(config, props); + config = withClerkFaceIDPermission(config, props); config = withClerkTheme(config, props); return config; }; @@ -361,6 +379,7 @@ const withClerkExpo = (config, props = {}) => { module.exports = withClerkExpo; module.exports._testing = { addHostedAuthIntentFilter, + withClerkFaceIDPermission, validateThemeJson, isPlainObject, VALID_COLOR_KEYS, diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 8c2f964ec2d..f2f04722ade 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -8,6 +8,7 @@ import Foundation // MARK: - Module public class ClerkExpoModule: Module { + private static let nativeAuthFlowChangedEvent = "clerkNativeAuthFlowChanged" private static let nativeClientChangedEvent = "clerkNativeClientChanged" private static weak var sharedInstance: ClerkExpoModule? @@ -15,10 +16,13 @@ public class ClerkExpoModule: Module { public func definition() -> ModuleDefinition { Name("ClerkExpo") - Events(Self.nativeClientChangedEvent) + Events(Self.nativeAuthFlowChangedEvent, Self.nativeClientChangedEvent) OnCreate { Self.sharedInstance = self + ClerkNativeBridge.setAuthFlowChangedEmitter { body in + Self.emitAuthFlowChanged(body) + } ClerkNativeBridge.setClientChangedEmitter { body in Self.emitClientChanged(body) } @@ -27,6 +31,7 @@ public class ClerkExpoModule: Module { OnDestroy { if Self.sharedInstance === self { Self.sharedInstance = nil + ClerkNativeBridge.setAuthFlowChangedEmitter(nil) ClerkNativeBridge.setClientChangedEmitter(nil) } } @@ -39,6 +44,10 @@ public class ClerkExpoModule: Module { self.getClientToken(promise: promise) } + AsyncFunction("getAuthFlowState") { (promise: Promise) in + self.getAuthFlowState(promise: promise) + } + AsyncFunction("syncClientStateFromJs") { (deviceToken: String?, sourceId: String?, @@ -53,6 +62,44 @@ public class ClerkExpoModule: Module { promise: promise ) } + + AsyncFunction("getTrustedDeviceAvailability") { + (id: String?, identifierHint: String?, promise: Promise) in + self.getTrustedDeviceAvailability(id: id, identifierHint: identifierHint, promise: promise) + } + + AsyncFunction("listTrustedDevices") { (promise: Promise) in + self.listTrustedDevices(promise: promise) + } + + AsyncFunction("enrollTrustedDevice") { + (deviceName: String?, + identifierHint: String?, + reason: String?, + policy: String, + promise: Promise) in + self.enrollTrustedDevice( + deviceName: deviceName, + identifierHint: identifierHint, + reason: reason, + policy: policy, + promise: promise + ) + } + + AsyncFunction("revokeTrustedDevice") { (id: String, promise: Promise) in + self.revokeTrustedDevice(id: id, promise: promise) + } + + AsyncFunction("signInWithTrustedDevice") { + (id: String?, identifierHint: String?, reason: String?, promise: Promise) in + self.signInWithTrustedDevice( + id: id, + identifierHint: identifierHint, + reason: reason, + promise: promise + ) + } } // MARK: - configure @@ -77,6 +124,15 @@ public class ClerkExpoModule: Module { } } + // MARK: - getAuthFlowState + + private func getAuthFlowState(promise: Promise) { + Task { @MainActor in + let state = ClerkNativeBridge.shared.getAuthFlowState() + promise.resolve(state) + } + } + // MARK: - syncClientStateFromJs private func syncClientStateFromJs(_ deviceToken: String?, @@ -99,6 +155,118 @@ public class ClerkExpoModule: Module { } } + // MARK: - Trusted devices + + private func getTrustedDeviceAvailability(id: String?, identifierHint: String?, promise: Promise) { + Task { @MainActor in + do { + let availability = try await ClerkNativeBridge.shared.getTrustedDeviceAvailability( + id: id, + identifierHint: identifierHint + ) + promise.resolve(availability) + } catch { + rejectTrustedDeviceError( + error, + fallbackCode: "E_TRUSTED_DEVICE_AVAILABILITY_FAILED", + promise: promise + ) + } + } + } + + private func listTrustedDevices(promise: Promise) { + Task { @MainActor in + do { + let trustedDevices = try await ClerkNativeBridge.shared.listTrustedDevices() + promise.resolve(trustedDevices) + } catch { + rejectTrustedDeviceError( + error, + fallbackCode: "E_TRUSTED_DEVICE_LIST_FAILED", + promise: promise + ) + } + } + } + + private func enrollTrustedDevice( + deviceName: String?, + identifierHint: String?, + reason: String?, + policy: String, + promise: Promise + ) { + Task { @MainActor in + do { + let trustedDevice = try await ClerkNativeBridge.shared.enrollTrustedDevice( + deviceName: deviceName, + identifierHint: identifierHint, + reason: reason, + policy: policy + ) + promise.resolve(trustedDevice) + } catch { + rejectTrustedDeviceError( + error, + fallbackCode: "E_TRUSTED_DEVICE_ENROLLMENT_FAILED", + promise: promise + ) + } + } + } + + private func revokeTrustedDevice(id: String, promise: Promise) { + Task { @MainActor in + do { + let trustedDevice = try await ClerkNativeBridge.shared.revokeTrustedDevice(id: id) + promise.resolve(trustedDevice) + } catch { + rejectTrustedDeviceError( + error, + fallbackCode: "E_TRUSTED_DEVICE_REVOCATION_FAILED", + promise: promise + ) + } + } + } + + private func signInWithTrustedDevice( + id: String?, + identifierHint: String?, + reason: String?, + promise: Promise + ) { + Task { @MainActor in + do { + let signIn = try await ClerkNativeBridge.shared.signInWithTrustedDevice( + id: id, + identifierHint: identifierHint, + reason: reason + ) + promise.resolve(signIn) + } catch { + rejectTrustedDeviceError( + error, + fallbackCode: "E_TRUSTED_DEVICE_SIGN_IN_FAILED", + promise: promise + ) + } + } + } + + private func rejectTrustedDeviceError( + _ error: Error, + fallbackCode: String, + promise: Promise + ) { + let descriptor = ClerkNativeBridge.trustedDeviceErrorDescriptor( + error, + fallbackCode: fallbackCode + ) + promise.reject(descriptor.code, descriptor.message) + } + /// Emits a native client change event to JS from anywhere in the native layer. /// Used by native views to ask ClerkProvider to reload JS client state. static func emitClientChanged(_ body: [String: Any]? = nil) { @@ -112,4 +280,16 @@ public class ClerkExpoModule: Module { instance?.sendEvent(Self.nativeClientChangedEvent, eventBody) } } + + static func emitAuthFlowChanged(_ body: [String: Any]? = nil) { + let eventBody = body ?? [:] + + guard let instance = sharedInstance else { + return + } + + DispatchQueue.main.async { [weak instance] in + instance?.sendEvent(Self.nativeAuthFlowChangedEvent, eventBody) + } + } } diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index fccd156a501..51d11aa26fb 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -43,8 +43,23 @@ final class ClerkInlineAuthLogoState { } private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events") +private var clerkNativeAuthFlowChangedEmitter: (([String: Any]?) -> Void)? private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)? +struct ClerkNativeErrorDescriptor { + let code: String + let message: String +} + +private struct ClerkExpoTrustedDeviceError: LocalizedError { + let code: String + let message: String + + var errorDescription: String? { + message + } +} + private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware { private static var hostSdkVersion: String? { Bundle.main.object(forInfoDictionaryKey: "ClerkExpoVersion") as? String @@ -74,6 +89,8 @@ final class ClerkNativeBridge { private var clientObservationGeneration = 0 private var lastObservedClientState: ClientStateSnapshot? + private var authFlowObservationGeneration = 0 + private var lastObservedAuthFlowState: AuthFlowStateSnapshot? private var configurationDepth = 0 private var jsOriginatedClientSyncDepth = 0 @@ -84,6 +101,11 @@ final class ClerkNativeBridge { let deviceToken: String? } + private struct AuthFlowStateSnapshot: Equatable { + let isLoaded: Bool + let isAuthFlowComplete: Bool + } + private struct ClientStateChanges { let client: Bool let deviceToken: Bool @@ -105,7 +127,10 @@ final class ClerkNativeBridge { configurationDepth += 1 defer { lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil + let authFlowState = Self.authFlowStateSnapshot() + lastObservedAuthFlowState = authFlowState configurationDepth = max(0, configurationDepth - 1) + Self.emitAuthFlowChanged(Self.authFlowStatePayload(authFlowState)) } loadThemes() @@ -115,6 +140,7 @@ final class ClerkNativeBridge { Self.clerkConfigured = true Self.configuredPublishableKey = publishableKey startClientObserver(reset: true) + startAuthFlowObserver(reset: true) let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) @@ -124,6 +150,7 @@ final class ClerkNativeBridge { if Self.clerkConfigured { startClientObserver() + startAuthFlowObserver() let didUpdateDeviceToken = try await Self.syncTokenState(bearerToken: bearerToken) if didUpdateDeviceToken { await Self.waitForLoadedClient() @@ -140,6 +167,7 @@ final class ClerkNativeBridge { Self.configuredPublishableKey = publishableKey Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) startClientObserver() + startAuthFlowObserver() let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) await Self.waitForLoadedClientIfNeeded(shouldWaitForClient) @@ -187,6 +215,60 @@ final class ClerkNativeBridge { } } + @MainActor + private func startAuthFlowObserver(reset: Bool = false) { + guard reset || authFlowObservationGeneration == 0 else { + return + } + + authFlowObservationGeneration += 1 + let generation = authFlowObservationGeneration + lastObservedAuthFlowState = Self.authFlowStateSnapshot() + observeAuthFlow(generation: generation) + } + + @MainActor + private func observeAuthFlow(generation: Int) { + withObservationTracking { + _ = Self.authFlowStateSnapshot() + } onChange: { [weak self] in + Task { @MainActor [weak self] in + await Task.yield() + + guard let self, generation == self.authFlowObservationGeneration else { return } + + let newState = Self.authFlowStateSnapshot() + if let previousState = self.lastObservedAuthFlowState, newState != previousState { + self.lastObservedAuthFlowState = newState + if self.configurationDepth == 0 { + Self.emitAuthFlowChanged(Self.authFlowStatePayload(newState)) + } + } + + self.observeAuthFlow(generation: generation) + } + } + } + + @MainActor + private static func authFlowStateSnapshot() -> AuthFlowStateSnapshot { + guard clerkConfigured else { + return AuthFlowStateSnapshot(isLoaded: false, isAuthFlowComplete: false) + } + + return AuthFlowStateSnapshot( + isLoaded: Clerk.shared.isLoaded, + isAuthFlowComplete: Clerk.shared.isAuthFlowComplete + ) + } + + private static func authFlowStatePayload(_ state: AuthFlowStateSnapshot) -> [String: Any] { + [ + "isLoaded": state.isLoaded, + "isAuthFlowComplete": state.isAuthFlowComplete, + ] + } + @MainActor private static func clientStateSnapshot() -> ClientStateSnapshot { let client = Clerk.shared.client @@ -264,6 +346,184 @@ final class ClerkNativeBridge { return Clerk.shared.deviceToken } + @MainActor + func getAuthFlowState() -> [String: Any] { + Self.authFlowStatePayload(Self.authFlowStateSnapshot()) + } + + // MARK: - Trusted devices + + @MainActor + func getTrustedDeviceAvailability(id: String?, identifierHint: String?) async throws -> [String: Any] { + let availability = try await Clerk.shared.trustedDevices.availability( + id: id, + identifierHint: identifierHint + ) + + return [ + "isAvailable": availability.isAvailable, + "unavailableReason": availability.unavailableReason + .map(Self.trustedDeviceUnavailableReason) ?? NSNull(), + ] + } + + @MainActor + func listTrustedDevices() async throws -> [[String: Any]] { + let trustedDevices = try await Clerk.shared.trustedDevices.list() + return trustedDevices.map(Self.trustedDevicePayload) + } + + @MainActor + func enrollTrustedDevice( + deviceName: String?, + identifierHint: String?, + reason: String?, + policy: String + ) async throws -> [String: Any] { + guard let trustedDevicePolicy = TrustedDevicePolicy(rawValue: policy) else { + throw ClerkExpoTrustedDeviceError( + code: "invalid_trusted_device_policy", + message: "Invalid trusted-device policy: \(policy)." + ) + } + + let trustedDevice = try await Clerk.shared.trustedDevices.enroll( + deviceName: deviceName, + identifierHint: identifierHint, + reason: reason, + policy: trustedDevicePolicy + ) + return Self.trustedDevicePayload(trustedDevice) + } + + @MainActor + func revokeTrustedDevice(id: String) async throws -> [String: Any] { + let trustedDevice = try await Clerk.shared.trustedDevices.revoke(id: id) + return Self.trustedDevicePayload(trustedDevice) + } + + @MainActor + func signInWithTrustedDevice( + id: String?, + identifierHint: String?, + reason: String? + ) async throws -> [String: Any] { + let signIn = try await Clerk.shared.auth.signInWithTrustedDevice( + id: id, + identifierHint: identifierHint, + reason: reason + ) + + return [ + "status": signIn.status.rawValue, + "createdSessionId": Self.bridgeValue(signIn.createdSessionId), + ] + } + + private static func trustedDevicePayload(_ trustedDevice: TrustedDevice) -> [String: Any] { + [ + "id": trustedDevice.id, + "object": trustedDevice.object, + "platform": trustedDevice.platform.rawValue, + "appIdentifier": trustedDevice.appIdentifier, + "name": bridgeValue(trustedDevice.name), + "algorithm": trustedDevice.algorithm.rawValue, + "status": trustedDevice.status.rawValue, + "createdAt": millisecondsSince1970(trustedDevice.createdAt), + "updatedAt": millisecondsSince1970(trustedDevice.updatedAt), + "lastUsedAt": optionalMillisecondsSince1970(trustedDevice.lastUsedAt), + "revokedAt": optionalMillisecondsSince1970(trustedDevice.revokedAt), + ] + } + + private static func trustedDeviceUnavailableReason( + _ reason: TrustedDeviceAvailability.UnavailableReason + ) -> String { + snakeCase(reason.rawValue) + } + + static func trustedDeviceErrorDescriptor( + _ error: Error, + fallbackCode: String + ) -> ClerkNativeErrorDescriptor { + if let error = error as? ClerkExpoTrustedDeviceError { + return ClerkNativeErrorDescriptor(code: error.code, message: error.localizedDescription) + } + + if let error = error as? ClerkAPIError { + return ClerkNativeErrorDescriptor(code: error.code, message: error.localizedDescription) + } + + if let error = error as? TrustedDeviceKeyManagerError { + return ClerkNativeErrorDescriptor( + code: trustedDeviceKeyManagerErrorCode(error), + message: error.localizedDescription + ) + } + + return ClerkNativeErrorDescriptor(code: fallbackCode, message: error.localizedDescription) + } + + private static func trustedDeviceKeyManagerErrorCode( + _ error: TrustedDeviceKeyManagerError + ) -> String { + switch error { + case .unsupportedPlatform: + "unsupported_platform" + case .biometricAuthenticationUnavailable: + "biometric_authentication_unavailable" + case .biometricAuthenticationCanceled: + "biometric_authentication_canceled" + case .biometricAuthenticationFailed: + "biometric_authentication_failed" + case .keyGenerationFailed: + "key_generation_failed" + case .keyNotFound: + "key_not_found" + case .invalidPublicKey: + "invalid_public_key" + case .publicKeyExportFailed: + "public_key_export_failed" + case .unsupportedAlgorithm: + "unsupported_algorithm" + case .signingFailed: + "signing_failed" + case .deletionFailed: + "key_deletion_failed" + @unknown default: + "trusted_device_key_manager_error" + } + } + + private static func snakeCase(_ value: String) -> String { + value + .replacingOccurrences( + of: "([A-Z]+)([A-Z][a-z])", + with: "$1_$2", + options: .regularExpression + ) + .replacingOccurrences( + of: "([a-z0-9])([A-Z])", + with: "$1_$2", + options: .regularExpression + ) + .lowercased() + } + + private static func millisecondsSince1970(_ date: Date) -> Double { + date.timeIntervalSince1970 * 1_000 + } + + private static func optionalMillisecondsSince1970(_ date: Date?) -> Any { + guard let date else { return NSNull() } + return millisecondsSince1970(date) + } + + private static func bridgeValue(_ value: Value?) -> Any { + guard let value else { return NSNull() } + return value + } + // MARK: - Inline View Creation func makeAuthViewController( @@ -389,6 +649,19 @@ final class ClerkNativeBridge { } } + static func setAuthFlowChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) { + clerkNativeClientEventQueue.sync { + clerkNativeAuthFlowChangedEmitter = emitter + } + } + + static func emitAuthFlowChanged(_ body: [String: Any]? = nil) { + let emitter = clerkNativeClientEventQueue.sync { + clerkNativeAuthFlowChangedEmitter + } + emitter?(body) + } + /// Requests that ClerkProvider reload the JS client from native client state. static func emitClientChanged(_ body: [String: Any]? = nil) { let emitter = clerkNativeClientEventQueue.sync { diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo/src/__tests__/appPlugin.theme.test.js index 24abc304e74..fcff7577b0e 100644 --- a/packages/expo/src/__tests__/appPlugin.theme.test.js +++ b/packages/expo/src/__tests__/appPlugin.theme.test.js @@ -1,7 +1,55 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export -const { validateThemeJson } = require('../../app.plugin.js')._testing; +const clerkPlugin = require('../../app.plugin.js'); +const { withClerkFaceIDPermission, validateThemeJson } = clerkPlugin._testing; + +function applyInfoPlistMod(config, modResults) { + return config.mods.ios.infoPlist({ + ...config, + modRequest: {}, + modResults, + }); +} + +describe('withClerkFaceIDPermission', () => { + test('adds the configured Face ID usage description', async () => { + const config = withClerkFaceIDPermission( + { name: 'test', slug: 'test' }, + { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' }, + ); + + const result = await applyInfoPlistMod(config, {}); + + expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.'); + }); + + test('preserves an app-provided Face ID usage description', async () => { + const config = withClerkFaceIDPermission( + { name: 'test', slug: 'test' }, + { faceIDPermission: 'Clerk-provided description' }, + ); + + const result = await applyInfoPlistMod(config, { + NSFaceIDUsageDescription: 'App-provided description', + }); + + expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description'); + }); + + test('does not configure the Info.plist without an explicit permission description', () => { + const config = { name: 'test', slug: 'test' }; + + expect(withClerkFaceIDPermission(config)).toBe(config); + expect(config).not.toHaveProperty('mods'); + }); + + test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => { + expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow( + 'faceIDPermission must be a non-empty string', + ); + }); +}); describe('validateThemeJson', () => { beforeEach(() => { diff --git a/packages/expo/src/index.ts b/packages/expo/src/index.ts index 8974c7371d6..e826626e21f 100644 --- a/packages/expo/src/index.ts +++ b/packages/expo/src/index.ts @@ -13,6 +13,7 @@ export { getClerkInstance } from './provider/singleton'; export * from './provider/ClerkProvider'; export * from './hooks'; export * from './components'; +export * from './trusted-devices'; // Override Clerk React error thrower to show that errors come from @clerk/expo setErrorThrowerOptions({ packageName: PACKAGE_NAME }); diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index 3f4b51ee346..4f1a52216d4 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -16,25 +16,22 @@ type AuthNativeEvent = NativeSyntheticEvent>; * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android * * After authentication completes, the session is automatically synced with the JS SDK. - * Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication - * state changes. + * Use `useAuthFlow()` when this is a non-dismissible root view so Clerk-owned + * post-authentication steps finish before authenticated content replaces it. * * To push the auth flow onto your own navigation stack, hide the route's header and * pass `onHostBack` so Clerk's own chrome takes over. * * @example * ```tsx - * import { AuthView } from '@clerk/expo/native'; - * import { useAuth } from '@clerk/expo'; + * import { AuthView, useAuthFlow } from '@clerk/expo/native'; * - * export default function SignInScreen() { - * const { isSignedIn } = useAuth(); + * export default function RootScreen() { + * const { isLoaded, isAuthFlowComplete } = useAuthFlow(); * - * useEffect(() => { - * if (isSignedIn) router.replace('/home'); - * }, [isSignedIn]); + * if (!isLoaded) return null; * - * return ; + * return isAuthFlowComplete ? : ; * } * ``` * diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts index 90c7b263c1d..3bcd1c2f4f4 100644 --- a/packages/expo/src/native/AuthView.types.ts +++ b/packages/expo/src/native/AuthView.types.ts @@ -15,8 +15,8 @@ export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp'; * Props for the AuthView component. * * AuthView renders a native authentication UI inline (fills parent container). - * Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication - * state changes. + * Use `useAuthFlow()` to gate authenticated content when AuthView is a + * non-dismissible root view. */ export interface AuthViewProps extends EmbeddedNavigationProps { /** diff --git a/packages/expo/src/native/__tests__/useAuthFlow.test.tsx b/packages/expo/src/native/__tests__/useAuthFlow.test.tsx new file mode 100644 index 00000000000..862f64aa9e5 --- /dev/null +++ b/packages/expo/src/native/__tests__/useAuthFlow.test.tsx @@ -0,0 +1,91 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { NativeAuthFlowState } from '../../specs/NativeClerkModule.types'; +import { useAuthFlow } from '../useAuthFlow'; + +const mocks = vi.hoisted(() => ({ + auth: { isLoaded: true, isSignedIn: true }, + getAuthFlowState: vi.fn(), + listener: undefined as ((state?: NativeAuthFlowState) => void) | undefined, + module: {} as unknown, + moduleAddListener: vi.fn(), + remove: vi.fn(), +})); + +vi.mock('../../hooks/useAuth', () => ({ + useAuth: () => mocks.auth, +})); + +vi.mock('../../utils/native-module', () => ({ + get ClerkExpoModule() { + return mocks.module; + }, +})); + +describe('useAuthFlow', () => { + beforeEach(() => { + mocks.auth = { isLoaded: true, isSignedIn: true }; + mocks.listener = undefined; + mocks.remove.mockReset(); + mocks.getAuthFlowState.mockReset(); + mocks.getAuthFlowState.mockResolvedValue({ isLoaded: true, isAuthFlowComplete: false }); + mocks.moduleAddListener.mockReset(); + mocks.moduleAddListener.mockImplementation((_eventName, listener) => { + mocks.listener = listener; + return { remove: mocks.remove }; + }); + mocks.module = { + addListener: mocks.moduleAddListener, + getAuthFlowState: mocks.getAuthFlowState, + }; + }); + + afterEach(() => { + cleanup(); + }); + + test('loads and observes native auth-flow completion state', async () => { + const { result, unmount } = renderHook(() => useAuthFlow()); + + expect(mocks.moduleAddListener).toHaveBeenCalledWith('clerkNativeAuthFlowChanged', expect.any(Function)); + + await waitFor(() => { + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: false }); + }); + + act(() => { + mocks.listener?.({ isLoaded: true, isAuthFlowComplete: true }); + }); + + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + + unmount(); + expect(mocks.remove).toHaveBeenCalledTimes(1); + }); + + test('waits for the JS session after the native auth flow completes', async () => { + mocks.auth = { isLoaded: true, isSignedIn: false }; + mocks.getAuthFlowState.mockResolvedValue({ isLoaded: true, isAuthFlowComplete: true }); + + const { result, rerender } = renderHook(() => useAuthFlow()); + + await waitFor(() => { + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: false }); + }); + + mocks.auth = { isLoaded: true, isSignedIn: true }; + rerender(); + + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + }); + + test('falls back to JS session state when native auth-flow state is unavailable', () => { + mocks.module = null; + + const { result } = renderHook(() => useAuthFlow()); + + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + expect(mocks.moduleAddListener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index d892fb9a851..215d3d0f9c6 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -31,6 +31,8 @@ export { AuthView } from './AuthView'; export type { AuthViewProps, AuthViewMode } from './AuthView.types'; export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; +export { useAuthFlow } from './useAuthFlow'; +export type { UseAuthFlowReturn } from './useAuthFlow'; export { UserButton } from './UserButton'; export { UserProfileView } from './UserProfileView'; export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/native/useAuthFlow.ts b/packages/expo/src/native/useAuthFlow.ts new file mode 100644 index 00000000000..680681b1529 --- /dev/null +++ b/packages/expo/src/native/useAuthFlow.ts @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; + +import { useAuth } from '../hooks/useAuth'; +import type { NativeAuthFlowState } from '../specs/NativeClerkModule.types'; +import { ClerkExpoModule as ClerkExpo } from '../utils/native-module'; + +const nativeAuthFlowChangedEvent = 'clerkNativeAuthFlowChanged'; + +export type UseAuthFlowReturn = NativeAuthFlowState; + +type NativeAuthFlowEventEmitter = { + addListener( + eventName: typeof nativeAuthFlowChangedEvent, + listener: (state?: NativeAuthFlowState) => void, + ): { remove: () => void }; + getAuthFlowState(): Promise; +}; + +const initialNativeState: NativeAuthFlowState = { + isLoaded: false, + isAuthFlowComplete: false, +}; + +function getNativeAuthFlowModule(): NativeAuthFlowEventEmitter | null { + if (ClerkExpo && typeof ClerkExpo.addListener === 'function' && typeof ClerkExpo.getAuthFlowState === 'function') { + return ClerkExpo as NativeAuthFlowEventEmitter; + } + + return null; +} + +function isNativeAuthFlowState(state: NativeAuthFlowState | undefined): state is NativeAuthFlowState { + return typeof state?.isLoaded === 'boolean' && typeof state.isAuthFlowComplete === 'boolean'; +} + +/** + * Reports when authentication and Clerk-owned post-authentication steps are complete. + * + * Use this hook to choose between a non-dismissible root `AuthView` and the + * application's authenticated content. On platforms without native auth-flow + * completion state, it falls back to the JS session state. + */ +export function useAuthFlow(): UseAuthFlowReturn { + const { isLoaded: isJsLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const [nativeState, setNativeState] = useState(initialNativeState); + const [useJsFallback, setUseJsFallback] = useState(false); + const nativeModule = getNativeAuthFlowModule(); + + useEffect(() => { + if (!nativeModule) { + setUseJsFallback(true); + return; + } + + let isMounted = true; + let didReceiveEvent = false; + let subscription: { remove: () => void } | undefined; + + setUseJsFallback(false); + + try { + subscription = nativeModule.addListener(nativeAuthFlowChangedEvent, state => { + if (!isNativeAuthFlowState(state)) { + return; + } + + didReceiveEvent = true; + setNativeState(state); + }); + + void nativeModule + .getAuthFlowState() + .then(state => { + if (isMounted && !didReceiveEvent && isNativeAuthFlowState(state)) { + setNativeState(state); + } + }) + .catch(error => { + if (!isMounted) { + return; + } + + setUseJsFallback(true); + if (__DEV__) { + console.error('[useAuthFlow] Failed to get native auth-flow state:', error); + } + }); + } catch (error) { + setUseJsFallback(true); + if (__DEV__) { + console.error('[useAuthFlow] Failed to observe native auth-flow state:', error); + } + } + + return () => { + isMounted = false; + subscription?.remove(); + }; + }, [nativeModule]); + + if (!nativeModule || useJsFallback) { + return { + isLoaded: isJsLoaded, + isAuthFlowComplete: Boolean(isJsLoaded && isSignedIn), + }; + } + + return { + isLoaded: Boolean(isJsLoaded && nativeState.isLoaded), + isAuthFlowComplete: Boolean(isJsLoaded && isSignedIn && nativeState.isAuthFlowComplete), + }; +} diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index cced94ad12a..d43321ad832 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -1,6 +1,8 @@ import { requireOptionalNativeModule } from 'expo'; -interface Spec { +import type { NativeAuthFlowModule, NativeTrustedDeviceModule } from './NativeClerkModule.types'; + +interface Spec extends NativeAuthFlowModule, NativeTrustedDeviceModule { // Exposed by Expo Modules EventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index c8eb967e84d..0388eb0d121 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -1,6 +1,8 @@ import { requireOptionalNativeModule } from 'expo'; -export interface Spec { +import type { NativeAuthFlowModule, NativeTrustedDeviceModule } from './NativeClerkModule.types'; + +export interface Spec extends NativeAuthFlowModule, NativeTrustedDeviceModule { // Exposed by Expo Modules EventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts new file mode 100644 index 00000000000..6929dd3cec6 --- /dev/null +++ b/packages/expo/src/specs/NativeClerkModule.types.ts @@ -0,0 +1,45 @@ +import type { + TrustedDeviceAvailability, + TrustedDevicePolicy, + TrustedDeviceSignInResult, +} from '../trusted-devices/types'; + +export type NativeAuthFlowState = { + isLoaded: boolean; + isAuthFlowComplete: boolean; +}; + +export type NativeAuthFlowModule = { + getAuthFlowState(): Promise; +}; + +export type NativeTrustedDevice = { + id: string; + object: 'trusted_device'; + platform: 'ios' | 'android' | (string & {}); + appIdentifier: string; + name: string | null; + algorithm: 'ES256' | (string & {}); + status: 'active' | 'revoked' | (string & {}); + createdAt: number; + updatedAt: number; + lastUsedAt: number | null; + revokedAt: number | null; +}; + +export type NativeTrustedDeviceModule = { + getTrustedDeviceAvailability(id: string | null, identifierHint: string | null): Promise; + listTrustedDevices(): Promise; + enrollTrustedDevice( + deviceName: string | null, + identifierHint: string | null, + reason: string | null, + policy: TrustedDevicePolicy, + ): Promise; + revokeTrustedDevice(id: string): Promise; + signInWithTrustedDevice( + id: string | null, + identifierHint: string | null, + reason: string | null, + ): Promise; +}; diff --git a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts new file mode 100644 index 00000000000..61c85186653 --- /dev/null +++ b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { isTrustedDeviceError } from '../errors'; +import { useTrustedDevices as useTrustedDevicesOnUnsupportedPlatform } from '../useTrustedDevices'; +import { useTrustedDevices as useTrustedDevicesOnAndroid } from '../useTrustedDevices.android'; +import { useTrustedDevices as useTrustedDevicesOnIos } from '../useTrustedDevices.ios'; + +const mocks = vi.hoisted(() => ({ + nativeModule: { + getTrustedDeviceAvailability: vi.fn(), + listTrustedDevices: vi.fn(), + enrollTrustedDevice: vi.fn(), + revokeTrustedDevice: vi.fn(), + signInWithTrustedDevice: vi.fn(), + }, +})); + +vi.mock('../../utils/native-module', () => ({ + ClerkExpoModule: mocks.nativeModule, +})); + +vi.mock('react-native', () => ({ + Platform: { + OS: 'ios', + }, +})); + +const nativeTrustedDevice = { + id: 'td_123', + object: 'trusted_device' as const, + platform: 'ios' as const, + appIdentifier: 'com.example.app', + name: "Sean's iPhone", + algorithm: 'ES256' as const, + status: 'active' as const, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_100_000, + lastUsedAt: 1_700_000_200_000, + revokedAt: null, +}; + +describe('useTrustedDevices on iOS', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('checks availability for an optional credential selector', async () => { + mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({ + isAvailable: true, + unavailableReason: null, + }); + + const trustedDevices = useTrustedDevicesOnIos(); + const availability = await trustedDevices.getAvailability({ + id: 'td_123', + identifierHint: 'sean@example.com', + }); + + expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledWith('td_123', 'sean@example.com'); + expect(availability).toEqual({ isAvailable: true, unavailableReason: null }); + }); + + test('lists trusted devices and converts native timestamps to dates', async () => { + mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeTrustedDevice]); + + const [trustedDevice] = await useTrustedDevicesOnIos().list(); + + expect(trustedDevice).toEqual({ + ...nativeTrustedDevice, + createdAt: new Date(nativeTrustedDevice.createdAt), + updatedAt: new Date(nativeTrustedDevice.updatedAt), + lastUsedAt: new Date(nativeTrustedDevice.lastUsedAt), + revokedAt: null, + }); + }); + + test('enrolls with the safe default authentication policy', async () => { + mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeTrustedDevice); + + const trustedDevice = await useTrustedDevicesOnIos().enroll({ + deviceName: "Sean's iPhone", + identifierHint: 'sean@example.com', + reason: 'Use Face ID to trust this device.', + }); + + expect(mocks.nativeModule.enrollTrustedDevice).toHaveBeenCalledWith( + "Sean's iPhone", + 'sean@example.com', + 'Use Face ID to trust this device.', + 'biometry_or_device_passcode', + ); + expect(trustedDevice.createdAt).toEqual(new Date(nativeTrustedDevice.createdAt)); + }); + + test('revokes a trusted device by ID', async () => { + mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({ + ...nativeTrustedDevice, + status: 'revoked', + revokedAt: 1_700_000_300_000, + }); + + const trustedDevice = await useTrustedDevicesOnIos().revoke('td_123'); + + expect(mocks.nativeModule.revokeTrustedDevice).toHaveBeenCalledWith('td_123'); + expect(trustedDevice.status).toBe('revoked'); + expect(trustedDevice.revokedAt).toEqual(new Date(1_700_000_300_000)); + }); + + test('signs in through the native one-shot trusted-device flow', async () => { + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + status: 'complete', + createdSessionId: 'sess_123', + }); + + const result = await useTrustedDevicesOnIos().signIn({ + identifierHint: 'sean@example.com', + reason: 'Use Face ID to sign in.', + }); + + expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledWith( + null, + 'sean@example.com', + 'Use Face ID to sign in.', + ); + expect(result).toEqual({ status: 'complete', createdSessionId: 'sess_123' }); + }); + + test('preserves forward-compatible native values', async () => { + mocks.nativeModule.listTrustedDevices.mockResolvedValue([ + { + ...nativeTrustedDevice, + platform: 'visionos', + algorithm: 'ES384', + status: 'pending_review', + }, + ]); + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + status: 'future_sign_in_status', + createdSessionId: null, + }); + + const trustedDevices = useTrustedDevicesOnIos(); + const [device] = await trustedDevices.list(); + const signIn = await trustedDevices.signIn(); + + expect(device).toMatchObject({ + platform: 'visionos', + algorithm: 'ES384', + status: 'pending_review', + }); + expect(signIn.status).toBe('future_sign_in_status'); + }); + + test('preserves structured native errors', async () => { + const nativeError = Object.assign(new Error('Biometric authentication was canceled.'), { + code: 'biometric_authentication_canceled', + }); + mocks.nativeModule.signInWithTrustedDevice.mockRejectedValue(nativeError); + + const operation = useTrustedDevicesOnIos().signIn(); + + await expect(operation).rejects.toBe(nativeError); + await operation.catch(error => { + expect(isTrustedDeviceError(error)).toBe(true); + if (isTrustedDeviceError(error)) { + expect(error.code).toBe('biometric_authentication_canceled'); + } + }); + }); + + test('explains that the development client must contain the native methods', async () => { + const signInWithTrustedDevice = mocks.nativeModule.signInWithTrustedDevice; + Object.assign(mocks.nativeModule, { signInWithTrustedDevice: undefined }); + + await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( + 'Biometric trusted devices require a development build containing a compatible version of @clerk/expo.', + ); + + Object.assign(mocks.nativeModule, { signInWithTrustedDevice }); + }); +}); + +describe('useTrustedDevices on Android', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('uses the native trusted-device bridge', async () => { + mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({ + isAvailable: true, + unavailableReason: null, + }); + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + status: 'complete', + createdSessionId: 'sess_android', + }); + + const trustedDevices = useTrustedDevicesOnAndroid(); + + await expect(trustedDevices.getAvailability({ identifierHint: 'sean@example.com' })).resolves.toEqual({ + isAvailable: true, + unavailableReason: null, + }); + await expect(trustedDevices.signIn({ reason: 'Confirm your identity to sign in.' })).resolves.toEqual({ + status: 'complete', + createdSessionId: 'sess_android', + }); + expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledWith(null, 'sean@example.com'); + expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledWith( + null, + null, + 'Confirm your identity to sign in.', + ); + }); +}); + +describe('useTrustedDevices on unsupported platforms', () => { + test('reports unsupported availability without invoking native code', async () => { + const availability = await useTrustedDevicesOnUnsupportedPlatform().getAvailability(); + + expect(availability).toEqual({ + isAvailable: false, + unavailableReason: 'unsupported_platform', + }); + }); + + test('rejects operations that require the native implementation', async () => { + await expect(useTrustedDevicesOnUnsupportedPlatform().enroll()).rejects.toThrow( + 'Biometric trusted devices are currently only available on iOS and Android.', + ); + }); +}); diff --git a/packages/expo/src/trusted-devices/errors.ts b/packages/expo/src/trusted-devices/errors.ts new file mode 100644 index 00000000000..83be5a25aae --- /dev/null +++ b/packages/expo/src/trusted-devices/errors.ts @@ -0,0 +1,28 @@ +export type TrustedDeviceErrorCode = + | 'unsupported_platform' + | 'biometric_authentication_unavailable' + | 'biometric_authentication_canceled' + | 'biometric_authentication_failed' + | 'key_generation_failed' + | 'key_not_found' + | 'key_invalidated' + | 'invalid_public_key' + | 'public_key_export_failed' + | 'unsupported_algorithm' + | 'signing_failed' + | 'key_deletion_failed' + | 'invalid_trusted_device_policy' + | 'E_TRUSTED_DEVICE_AVAILABILITY_FAILED' + | 'E_TRUSTED_DEVICE_LIST_FAILED' + | 'E_TRUSTED_DEVICE_ENROLLMENT_FAILED' + | 'E_TRUSTED_DEVICE_REVOCATION_FAILED' + | 'E_TRUSTED_DEVICE_SIGN_IN_FAILED' + | (string & {}); + +export type TrustedDeviceError = Error & { + code: TrustedDeviceErrorCode; +}; + +export function isTrustedDeviceError(error: unknown): error is TrustedDeviceError { + return error instanceof Error && 'code' in error && typeof error.code === 'string'; +} diff --git a/packages/expo/src/trusted-devices/index.ts b/packages/expo/src/trusted-devices/index.ts new file mode 100644 index 00000000000..a7056efe72b --- /dev/null +++ b/packages/expo/src/trusted-devices/index.ts @@ -0,0 +1,3 @@ +export * from './errors'; +export * from './types'; +export * from './useTrustedDevices'; diff --git a/packages/expo/src/trusted-devices/types.ts b/packages/expo/src/trusted-devices/types.ts new file mode 100644 index 00000000000..6c9a71d2e8a --- /dev/null +++ b/packages/expo/src/trusted-devices/types.ts @@ -0,0 +1,69 @@ +import type { SignInStatus } from '@clerk/shared/types'; + +export type TrustedDeviceUnavailableReason = + | 'environment_unavailable' + | 'native_api_disabled' + | 'feature_disabled' + | 'unsupported_platform' + | 'biometric_authentication_unavailable' + | 'no_local_credential' + | 'local_key_missing' + | 'server_credential_missing' + | 'server_credential_revoked' + | (string & {}); + +export type TrustedDeviceAvailability = { + isAvailable: boolean; + unavailableReason: TrustedDeviceUnavailableReason | null; +}; + +export type TrustedDevicePolicy = 'biometry_current_set' | 'biometry_any' | 'biometry_or_device_passcode'; + +export type TrustedDevicePlatform = 'ios' | 'android' | (string & {}); + +export type TrustedDeviceStatus = 'active' | 'revoked' | (string & {}); + +export type TrustedDevice = { + id: string; + object: 'trusted_device'; + platform: TrustedDevicePlatform; + appIdentifier: string; + name: string | null; + algorithm: 'ES256' | (string & {}); + status: TrustedDeviceStatus; + createdAt: Date; + updatedAt: Date; + lastUsedAt: Date | null; + revokedAt: Date | null; +}; + +export type GetTrustedDeviceAvailabilityParams = { + id?: string; + identifierHint?: string; +}; + +export type EnrollTrustedDeviceParams = { + deviceName?: string; + identifierHint?: string; + reason?: string; + policy?: TrustedDevicePolicy; +}; + +export type SignInWithTrustedDeviceParams = { + id?: string; + identifierHint?: string; + reason?: string; +}; + +export type TrustedDeviceSignInResult = { + status: SignInStatus | (string & {}); + createdSessionId: string | null; +}; + +export type UseTrustedDevicesReturn = { + getAvailability: (params?: GetTrustedDeviceAvailabilityParams) => Promise; + list: () => Promise; + enroll: (params?: EnrollTrustedDeviceParams) => Promise; + revoke: (id: string) => Promise; + signIn: (params?: SignInWithTrustedDeviceParams) => Promise; +}; diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.android.ts b/packages/expo/src/trusted-devices/useTrustedDevices.android.ts new file mode 100644 index 00000000000..124e32e9a0e --- /dev/null +++ b/packages/expo/src/trusted-devices/useTrustedDevices.android.ts @@ -0,0 +1 @@ +export { useTrustedDevices } from './useTrustedDevices.shared'; diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.ios.ts b/packages/expo/src/trusted-devices/useTrustedDevices.ios.ts new file mode 100644 index 00000000000..124e32e9a0e --- /dev/null +++ b/packages/expo/src/trusted-devices/useTrustedDevices.ios.ts @@ -0,0 +1 @@ +export { useTrustedDevices } from './useTrustedDevices.shared'; diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts new file mode 100644 index 00000000000..852ec8c73e5 --- /dev/null +++ b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts @@ -0,0 +1,73 @@ +import type { NativeTrustedDevice, NativeTrustedDeviceModule } from '../specs/NativeClerkModule.types'; +import { errorThrower } from '../utils/errors'; +import { ClerkExpoModule } from '../utils/native-module'; +import type { TrustedDevice, UseTrustedDevicesReturn } from './types'; + +const DEFAULT_POLICY = 'biometry_or_device_passcode'; + +function getNativeModule(): NativeTrustedDeviceModule { + const nativeModule = ClerkExpoModule; + + if ( + !nativeModule?.getTrustedDeviceAvailability || + !nativeModule.listTrustedDevices || + !nativeModule.enrollTrustedDevice || + !nativeModule.revokeTrustedDevice || + !nativeModule.signInWithTrustedDevice + ) { + return errorThrower.throw( + 'Biometric trusted devices require a development build containing a compatible version of @clerk/expo.', + ); + } + + return nativeModule as NativeTrustedDeviceModule; +} + +function toTrustedDevice(device: NativeTrustedDevice): TrustedDevice { + return { + ...device, + createdAt: new Date(device.createdAt), + updatedAt: new Date(device.updatedAt), + lastUsedAt: device.lastUsedAt === null ? null : new Date(device.lastUsedAt), + revokedAt: device.revokedAt === null ? null : new Date(device.revokedAt), + }; +} + +/** + * Accesses biometric trusted-device enrollment and sign-in on iOS and Android. + * + * The private key and biometric prompt are managed by Clerk's native SDK. + */ +export function useTrustedDevices(): UseTrustedDevicesReturn { + return { + getAvailability: params => + Promise.resolve().then(() => + getNativeModule().getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null), + ), + list: async () => { + const devices = await getNativeModule().listTrustedDevices(); + return devices.map(toTrustedDevice); + }, + enroll: async params => { + const device = await getNativeModule().enrollTrustedDevice( + params?.deviceName ?? null, + params?.identifierHint ?? null, + params?.reason ?? null, + params?.policy ?? DEFAULT_POLICY, + ); + return toTrustedDevice(device); + }, + revoke: async id => { + const device = await getNativeModule().revokeTrustedDevice(id); + return toTrustedDevice(device); + }, + signIn: params => + Promise.resolve().then(() => + getNativeModule().signInWithTrustedDevice( + params?.id ?? null, + params?.identifierHint ?? null, + params?.reason ?? null, + ), + ), + }; +} diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.ts b/packages/expo/src/trusted-devices/useTrustedDevices.ts new file mode 100644 index 00000000000..2eff82f9f0d --- /dev/null +++ b/packages/expo/src/trusted-devices/useTrustedDevices.ts @@ -0,0 +1,30 @@ +import { errorThrower } from '../utils/errors'; +import type { UseTrustedDevicesReturn } from './types'; + +const unsupportedAvailability = { + isAvailable: false, + unavailableReason: 'unsupported_platform', +} as const; + +function unsupported(): never { + return errorThrower.throw('Biometric trusted devices are currently only available on iOS and Android.'); +} + +function rejectUnsupported(): Promise { + return Promise.resolve().then(unsupported); +} + +/** + * Accesses biometric trusted-device enrollment and sign-in. + * + * Trusted devices are currently supported on iOS and Android. + */ +export function useTrustedDevices(): UseTrustedDevicesReturn { + return { + getAvailability: () => Promise.resolve(unsupportedAvailability), + list: rejectUnsupported, + enroll: rejectUnsupported, + revoke: rejectUnsupported, + signIn: rejectUnsupported, + }; +} diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index 1a852882e4e..08dfacdbd12 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -1,10 +1,11 @@ import { Platform } from 'react-native'; import NativeClerkModule from '../specs/NativeClerkModule'; +import type { NativeAuthFlowModule, NativeTrustedDeviceModule } from '../specs/NativeClerkModule.types'; export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; -type ClerkExpoNativeModule = { +export type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; @@ -14,7 +15,7 @@ type ClerkExpoNativeModule = { didChangeClient: boolean, didChangeDeviceToken: boolean, ): Promise; -}; +} & Partial; function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { if (!module || typeof module !== 'object') { From c5f43aac383072ff2daacbf5806ccc10ee4f678d Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 27 Jul 2026 15:07:31 -0400 Subject: [PATCH 02/10] fix(expo): fall back on invalid auth flow state --- .../expo/src/native/__tests__/useAuthFlow.test.tsx | 10 ++++++++++ packages/expo/src/native/useAuthFlow.ts | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/expo/src/native/__tests__/useAuthFlow.test.tsx b/packages/expo/src/native/__tests__/useAuthFlow.test.tsx index 862f64aa9e5..5c2bf3f916c 100644 --- a/packages/expo/src/native/__tests__/useAuthFlow.test.tsx +++ b/packages/expo/src/native/__tests__/useAuthFlow.test.tsx @@ -88,4 +88,14 @@ describe('useAuthFlow', () => { expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); expect(mocks.moduleAddListener).not.toHaveBeenCalled(); }); + + test('falls back to JS session state when the native auth-flow state is invalid', async () => { + mocks.getAuthFlowState.mockResolvedValue({ isLoaded: true }); + + const { result } = renderHook(() => useAuthFlow()); + + await waitFor(() => { + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + }); + }); }); diff --git a/packages/expo/src/native/useAuthFlow.ts b/packages/expo/src/native/useAuthFlow.ts index 680681b1529..af3d1acdc2d 100644 --- a/packages/expo/src/native/useAuthFlow.ts +++ b/packages/expo/src/native/useAuthFlow.ts @@ -71,8 +71,14 @@ export function useAuthFlow(): UseAuthFlowReturn { void nativeModule .getAuthFlowState() .then(state => { - if (isMounted && !didReceiveEvent && isNativeAuthFlowState(state)) { + if (!isMounted || didReceiveEvent) { + return; + } + + if (isNativeAuthFlowState(state)) { setNativeState(state); + } else { + setUseJsFallback(true); } }) .catch(error => { From 46d25f2a7f83aaab2691b62c7c2c8372d338c099 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 27 Jul 2026 16:00:58 -0400 Subject: [PATCH 03/10] docs(expo): clarify useAuthFlow usage --- packages/expo/src/native/AuthView.tsx | 17 ++++++++++------- packages/expo/src/native/AuthView.types.ts | 4 ++-- packages/expo/src/native/useAuthFlow.ts | 9 +++++---- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx index 4f1a52216d4..3f4b51ee346 100644 --- a/packages/expo/src/native/AuthView.tsx +++ b/packages/expo/src/native/AuthView.tsx @@ -16,22 +16,25 @@ type AuthNativeEvent = NativeSyntheticEvent>; * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android * * After authentication completes, the session is automatically synced with the JS SDK. - * Use `useAuthFlow()` when this is a non-dismissible root view so Clerk-owned - * post-authentication steps finish before authenticated content replaces it. + * Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication + * state changes. * * To push the auth flow onto your own navigation stack, hide the route's header and * pass `onHostBack` so Clerk's own chrome takes over. * * @example * ```tsx - * import { AuthView, useAuthFlow } from '@clerk/expo/native'; + * import { AuthView } from '@clerk/expo/native'; + * import { useAuth } from '@clerk/expo'; * - * export default function RootScreen() { - * const { isLoaded, isAuthFlowComplete } = useAuthFlow(); + * export default function SignInScreen() { + * const { isSignedIn } = useAuth(); * - * if (!isLoaded) return null; + * useEffect(() => { + * if (isSignedIn) router.replace('/home'); + * }, [isSignedIn]); * - * return isAuthFlowComplete ? : ; + * return ; * } * ``` * diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts index 3bcd1c2f4f4..90c7b263c1d 100644 --- a/packages/expo/src/native/AuthView.types.ts +++ b/packages/expo/src/native/AuthView.types.ts @@ -15,8 +15,8 @@ export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp'; * Props for the AuthView component. * * AuthView renders a native authentication UI inline (fills parent container). - * Use `useAuthFlow()` to gate authenticated content when AuthView is a - * non-dismissible root view. + * Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication + * state changes. */ export interface AuthViewProps extends EmbeddedNavigationProps { /** diff --git a/packages/expo/src/native/useAuthFlow.ts b/packages/expo/src/native/useAuthFlow.ts index af3d1acdc2d..d1821c2202a 100644 --- a/packages/expo/src/native/useAuthFlow.ts +++ b/packages/expo/src/native/useAuthFlow.ts @@ -34,11 +34,12 @@ function isNativeAuthFlowState(state: NativeAuthFlowState | undefined): state is } /** - * Reports when authentication and Clerk-owned post-authentication steps are complete. + * Reports when authentication and an optional trusted-device enrollment prompt are complete. * - * Use this hook to choose between a non-dismissible root `AuthView` and the - * application's authenticated content. On platforms without native auth-flow - * completion state, it falls back to the JS session state. + * Use this hook when trusted-device enrollment prompts are enabled and a + * non-dismissible root `AuthView` must remain mounted until the prompt finishes. + * On platforms without native auth-flow completion state, it falls back to the + * JS session state. */ export function useAuthFlow(): UseAuthFlowReturn { const { isLoaded: isJsLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); From c1d6966591d3edb6f8e529295388ddbdb7c910ae Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 31 Jul 2026 20:30:43 -0400 Subject: [PATCH 04/10] chore(repo): update project files --- .changeset/thin-spoons-trust.md | 33 ++++++++++++++++++- ...low.test.tsx => useAuthViewState.test.tsx} | 12 +++---- packages/expo/src/native/index.ts | 4 +-- .../{useAuthFlow.ts => useAuthViewState.ts} | 8 ++--- 4 files changed, 44 insertions(+), 13 deletions(-) rename packages/expo/src/native/__tests__/{useAuthFlow.test.tsx => useAuthViewState.test.tsx} (89%) rename packages/expo/src/native/{useAuthFlow.ts => useAuthViewState.ts} (91%) diff --git a/.changeset/thin-spoons-trust.md b/.changeset/thin-spoons-trust.md index 6f1cae618aa..93eed769894 100644 --- a/.changeset/thin-spoons-trust.md +++ b/.changeset/thin-spoons-trust.md @@ -2,4 +2,35 @@ '@clerk/expo': minor --- -Add iOS and Android APIs for biometric trusted-device enrollment, sign-in, availability, listing, and revocation, including structured native error codes and forward-compatible resource values. Add native authentication-flow readiness state for safely gating authenticated content and support configuring the Face ID permission message through the Expo config plugin. +Add iOS and Android APIs for biometric trusted-device enrollment, sign-in, availability, listing, and revocation, including structured native error codes and forward-compatible resource values. Add `useAuthViewState()` for keeping a non-dismissible root `` mounted through an optional trusted-device enrollment prompt, and support configuring the Face ID permission message through the Expo config plugin. + +```tsx +import { useTrustedDevices } from '@clerk/expo'; + +export function useBiometricSignIn(identifierHint: string) { + const { enroll, getAvailability, signIn } = useTrustedDevices(); + + // Call after the user completes a normal sign-in. + const enableBiometricSignIn = () => + enroll({ + identifierHint, + reason: 'Use biometrics to sign in next time.', + }); + + // Call when the user returns to sign in. + const signInWithBiometrics = async () => { + const { isAvailable } = await getAvailability({ identifierHint }); + + if (!isAvailable) { + return null; + } + + return signIn({ + identifierHint, + reason: 'Use biometrics to sign in.', + }); + }; + + return { enableBiometricSignIn, signInWithBiometrics }; +} +``` diff --git a/packages/expo/src/native/__tests__/useAuthFlow.test.tsx b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx similarity index 89% rename from packages/expo/src/native/__tests__/useAuthFlow.test.tsx rename to packages/expo/src/native/__tests__/useAuthViewState.test.tsx index 5c2bf3f916c..c3cb37730f3 100644 --- a/packages/expo/src/native/__tests__/useAuthFlow.test.tsx +++ b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { NativeAuthFlowState } from '../../specs/NativeClerkModule.types'; -import { useAuthFlow } from '../useAuthFlow'; +import { useAuthViewState } from '../useAuthViewState'; const mocks = vi.hoisted(() => ({ auth: { isLoaded: true, isSignedIn: true }, @@ -23,7 +23,7 @@ vi.mock('../../utils/native-module', () => ({ }, })); -describe('useAuthFlow', () => { +describe('useAuthViewState', () => { beforeEach(() => { mocks.auth = { isLoaded: true, isSignedIn: true }; mocks.listener = undefined; @@ -46,7 +46,7 @@ describe('useAuthFlow', () => { }); test('loads and observes native auth-flow completion state', async () => { - const { result, unmount } = renderHook(() => useAuthFlow()); + const { result, unmount } = renderHook(() => useAuthViewState()); expect(mocks.moduleAddListener).toHaveBeenCalledWith('clerkNativeAuthFlowChanged', expect.any(Function)); @@ -68,7 +68,7 @@ describe('useAuthFlow', () => { mocks.auth = { isLoaded: true, isSignedIn: false }; mocks.getAuthFlowState.mockResolvedValue({ isLoaded: true, isAuthFlowComplete: true }); - const { result, rerender } = renderHook(() => useAuthFlow()); + const { result, rerender } = renderHook(() => useAuthViewState()); await waitFor(() => { expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: false }); @@ -83,7 +83,7 @@ describe('useAuthFlow', () => { test('falls back to JS session state when native auth-flow state is unavailable', () => { mocks.module = null; - const { result } = renderHook(() => useAuthFlow()); + const { result } = renderHook(() => useAuthViewState()); expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); expect(mocks.moduleAddListener).not.toHaveBeenCalled(); @@ -92,7 +92,7 @@ describe('useAuthFlow', () => { test('falls back to JS session state when the native auth-flow state is invalid', async () => { mocks.getAuthFlowState.mockResolvedValue({ isLoaded: true }); - const { result } = renderHook(() => useAuthFlow()); + const { result } = renderHook(() => useAuthViewState()); await waitFor(() => { expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index 215d3d0f9c6..056bfa4c705 100644 --- a/packages/expo/src/native/index.ts +++ b/packages/expo/src/native/index.ts @@ -31,8 +31,8 @@ export { AuthView } from './AuthView'; export type { AuthViewProps, AuthViewMode } from './AuthView.types'; export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types'; -export { useAuthFlow } from './useAuthFlow'; -export type { UseAuthFlowReturn } from './useAuthFlow'; +export { useAuthViewState } from './useAuthViewState'; +export type { UseAuthViewStateReturn } from './useAuthViewState'; export { UserButton } from './UserButton'; export { UserProfileView } from './UserProfileView'; export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/native/useAuthFlow.ts b/packages/expo/src/native/useAuthViewState.ts similarity index 91% rename from packages/expo/src/native/useAuthFlow.ts rename to packages/expo/src/native/useAuthViewState.ts index d1821c2202a..4c67083db05 100644 --- a/packages/expo/src/native/useAuthFlow.ts +++ b/packages/expo/src/native/useAuthViewState.ts @@ -6,7 +6,7 @@ import { ClerkExpoModule as ClerkExpo } from '../utils/native-module'; const nativeAuthFlowChangedEvent = 'clerkNativeAuthFlowChanged'; -export type UseAuthFlowReturn = NativeAuthFlowState; +export type UseAuthViewStateReturn = NativeAuthFlowState; type NativeAuthFlowEventEmitter = { addListener( @@ -41,7 +41,7 @@ function isNativeAuthFlowState(state: NativeAuthFlowState | undefined): state is * On platforms without native auth-flow completion state, it falls back to the * JS session state. */ -export function useAuthFlow(): UseAuthFlowReturn { +export function useAuthViewState(): UseAuthViewStateReturn { const { isLoaded: isJsLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const [nativeState, setNativeState] = useState(initialNativeState); const [useJsFallback, setUseJsFallback] = useState(false); @@ -89,13 +89,13 @@ export function useAuthFlow(): UseAuthFlowReturn { setUseJsFallback(true); if (__DEV__) { - console.error('[useAuthFlow] Failed to get native auth-flow state:', error); + console.error('[useAuthViewState] Failed to get native auth-flow state:', error); } }); } catch (error) { setUseJsFallback(true); if (__DEV__) { - console.error('[useAuthFlow] Failed to observe native auth-flow state:', error); + console.error('[useAuthViewState] Failed to observe native auth-flow state:', error); } } From e4b6e7c6509b08559899075fe64143afcc1d18d4 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 13 Aug 2026 17:14:19 -0400 Subject: [PATCH 05/10] fix(expo): address trusted-device review feedback --- .../__tests__/useAuthViewState.test.tsx | 32 +++++++++ .../__tests__/useTrustedDevices.test.ts | 35 ++++++++-- .../useTrustedDevices.shared.ts | 68 ++++++++++--------- .../src/trusted-devices/useTrustedDevices.ts | 16 +++-- 4 files changed, 106 insertions(+), 45 deletions(-) diff --git a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx index c3cb37730f3..8be45d2ea99 100644 --- a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx +++ b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx @@ -43,6 +43,8 @@ describe('useAuthViewState', () => { afterEach(() => { cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); test('loads and observes native auth-flow completion state', async () => { @@ -98,4 +100,34 @@ describe('useAuthViewState', () => { expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); }); }); + + test('falls back to JS session state when the native auth-flow state rejects', async () => { + const error = new Error('native failure'); + vi.stubGlobal('__DEV__', true); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.getAuthFlowState.mockRejectedValue(error); + + const { result } = renderHook(() => useAuthViewState()); + + await waitFor(() => { + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + }); + expect(consoleError).toHaveBeenCalledWith('[useAuthViewState] Failed to get native auth-flow state:', error); + }); + + test('falls back to JS session state when the native listener cannot be installed', async () => { + const error = new Error('listener failure'); + vi.stubGlobal('__DEV__', true); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + mocks.moduleAddListener.mockImplementation(() => { + throw error; + }); + + const { result } = renderHook(() => useAuthViewState()); + + await waitFor(() => { + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + }); + expect(consoleError).toHaveBeenCalledWith('[useAuthViewState] Failed to observe native auth-flow state:', error); + }); }); diff --git a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts index 61c85186653..784070c23b5 100644 --- a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts +++ b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts @@ -74,6 +74,25 @@ describe('useTrustedDevices on iOS', () => { }); }); + test('maps omitted optional native timestamps to null', async () => { + mocks.nativeModule.listTrustedDevices.mockResolvedValue([ + { + ...nativeTrustedDevice, + lastUsedAt: undefined, + revokedAt: undefined, + }, + ]); + + const [trustedDevice] = await useTrustedDevicesOnIos().list(); + + expect(trustedDevice.lastUsedAt).toBeNull(); + expect(trustedDevice.revokedAt).toBeNull(); + }); + + test('returns stable operation identities', () => { + expect(useTrustedDevicesOnIos()).toBe(useTrustedDevicesOnIos()); + }); + test('enrolls with the safe default authentication policy', async () => { mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeTrustedDevice); @@ -172,11 +191,13 @@ describe('useTrustedDevices on iOS', () => { const signInWithTrustedDevice = mocks.nativeModule.signInWithTrustedDevice; Object.assign(mocks.nativeModule, { signInWithTrustedDevice: undefined }); - await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( - 'Biometric trusted devices require a development build containing a compatible version of @clerk/expo.', - ); - - Object.assign(mocks.nativeModule, { signInWithTrustedDevice }); + try { + await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( + 'Biometric trusted devices require a development build containing a compatible version of @clerk/expo.', + ); + } finally { + Object.assign(mocks.nativeModule, { signInWithTrustedDevice }); + } }); }); @@ -215,6 +236,10 @@ describe('useTrustedDevices on Android', () => { }); describe('useTrustedDevices on unsupported platforms', () => { + test('returns stable operation identities', () => { + expect(useTrustedDevicesOnUnsupportedPlatform()).toBe(useTrustedDevicesOnUnsupportedPlatform()); + }); + test('reports unsupported availability without invoking native code', async () => { const availability = await useTrustedDevicesOnUnsupportedPlatform().getAvailability(); diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts index 852ec8c73e5..3487f3da200 100644 --- a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts +++ b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts @@ -28,46 +28,48 @@ function toTrustedDevice(device: NativeTrustedDevice): TrustedDevice { ...device, createdAt: new Date(device.createdAt), updatedAt: new Date(device.updatedAt), - lastUsedAt: device.lastUsedAt === null ? null : new Date(device.lastUsedAt), - revokedAt: device.revokedAt === null ? null : new Date(device.revokedAt), + lastUsedAt: device.lastUsedAt == null ? null : new Date(device.lastUsedAt), + revokedAt: device.revokedAt == null ? null : new Date(device.revokedAt), }; } +const trustedDevices: UseTrustedDevicesReturn = Object.freeze({ + getAvailability: params => + Promise.resolve().then(() => + getNativeModule().getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null), + ), + list: async () => { + const devices = await getNativeModule().listTrustedDevices(); + return devices.map(toTrustedDevice); + }, + enroll: async params => { + const device = await getNativeModule().enrollTrustedDevice( + params?.deviceName ?? null, + params?.identifierHint ?? null, + params?.reason ?? null, + params?.policy ?? DEFAULT_POLICY, + ); + return toTrustedDevice(device); + }, + revoke: async id => { + const device = await getNativeModule().revokeTrustedDevice(id); + return toTrustedDevice(device); + }, + signIn: params => + Promise.resolve().then(() => + getNativeModule().signInWithTrustedDevice( + params?.id ?? null, + params?.identifierHint ?? null, + params?.reason ?? null, + ), + ), +}); + /** * Accesses biometric trusted-device enrollment and sign-in on iOS and Android. * * The private key and biometric prompt are managed by Clerk's native SDK. */ export function useTrustedDevices(): UseTrustedDevicesReturn { - return { - getAvailability: params => - Promise.resolve().then(() => - getNativeModule().getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null), - ), - list: async () => { - const devices = await getNativeModule().listTrustedDevices(); - return devices.map(toTrustedDevice); - }, - enroll: async params => { - const device = await getNativeModule().enrollTrustedDevice( - params?.deviceName ?? null, - params?.identifierHint ?? null, - params?.reason ?? null, - params?.policy ?? DEFAULT_POLICY, - ); - return toTrustedDevice(device); - }, - revoke: async id => { - const device = await getNativeModule().revokeTrustedDevice(id); - return toTrustedDevice(device); - }, - signIn: params => - Promise.resolve().then(() => - getNativeModule().signInWithTrustedDevice( - params?.id ?? null, - params?.identifierHint ?? null, - params?.reason ?? null, - ), - ), - }; + return trustedDevices; } diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.ts b/packages/expo/src/trusted-devices/useTrustedDevices.ts index 2eff82f9f0d..805387c9348 100644 --- a/packages/expo/src/trusted-devices/useTrustedDevices.ts +++ b/packages/expo/src/trusted-devices/useTrustedDevices.ts @@ -14,17 +14,19 @@ function rejectUnsupported(): Promise { return Promise.resolve().then(unsupported); } +const trustedDevices: UseTrustedDevicesReturn = Object.freeze({ + getAvailability: () => Promise.resolve(unsupportedAvailability), + list: rejectUnsupported, + enroll: rejectUnsupported, + revoke: rejectUnsupported, + signIn: rejectUnsupported, +}); + /** * Accesses biometric trusted-device enrollment and sign-in. * * Trusted devices are currently supported on iOS and Android. */ export function useTrustedDevices(): UseTrustedDevicesReturn { - return { - getAvailability: () => Promise.resolve(unsupportedAvailability), - list: rejectUnsupported, - enroll: rejectUnsupported, - revoke: rejectUnsupported, - signIn: rejectUnsupported, - }; + return trustedDevices; } From 033a17083669de6b8881e2f795f311b0d96dea6b Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 13 Aug 2026 18:44:59 -0400 Subject: [PATCH 06/10] fix(expo): guard trusted devices before configuration --- packages/expo/ios/ClerkNativeBridge.swift | 22 +++++++ .../ios/Tests/ClerkNativeBridgeTests.swift | 58 +++++++++++++++++++ packages/expo/src/trusted-devices/errors.ts | 1 + 3 files changed, 81 insertions(+) create mode 100644 packages/expo/ios/Tests/ClerkNativeBridgeTests.swift diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 51d11aa26fb..711a747b9f3 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -355,6 +355,13 @@ final class ClerkNativeBridge { @MainActor func getTrustedDeviceAvailability(id: String?, identifierHint: String?) async throws -> [String: Any] { + guard Self.clerkConfigured else { + return [ + "isAvailable": false, + "unavailableReason": "environment_unavailable", + ] + } + let availability = try await Clerk.shared.trustedDevices.availability( id: id, identifierHint: identifierHint @@ -369,6 +376,7 @@ final class ClerkNativeBridge { @MainActor func listTrustedDevices() async throws -> [[String: Any]] { + try Self.requireTrustedDeviceEnvironment() let trustedDevices = try await Clerk.shared.trustedDevices.list() return trustedDevices.map(Self.trustedDevicePayload) } @@ -380,6 +388,8 @@ final class ClerkNativeBridge { reason: String?, policy: String ) async throws -> [String: Any] { + try Self.requireTrustedDeviceEnvironment() + guard let trustedDevicePolicy = TrustedDevicePolicy(rawValue: policy) else { throw ClerkExpoTrustedDeviceError( code: "invalid_trusted_device_policy", @@ -398,6 +408,7 @@ final class ClerkNativeBridge { @MainActor func revokeTrustedDevice(id: String) async throws -> [String: Any] { + try Self.requireTrustedDeviceEnvironment() let trustedDevice = try await Clerk.shared.trustedDevices.revoke(id: id) return Self.trustedDevicePayload(trustedDevice) } @@ -408,6 +419,7 @@ final class ClerkNativeBridge { identifierHint: String?, reason: String? ) async throws -> [String: Any] { + try Self.requireTrustedDeviceEnvironment() let signIn = try await Clerk.shared.auth.signInWithTrustedDevice( id: id, identifierHint: identifierHint, @@ -420,6 +432,16 @@ final class ClerkNativeBridge { ] } + @MainActor + private static func requireTrustedDeviceEnvironment() throws { + guard clerkConfigured else { + throw ClerkExpoTrustedDeviceError( + code: "environment_unavailable", + message: "Trusted-device operations are unavailable until Clerk finishes configuring." + ) + } + } + private static func trustedDevicePayload(_ trustedDevice: TrustedDevice) -> [String: Any] { [ "id": trustedDevice.id, diff --git a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift b/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift new file mode 100644 index 00000000000..ca4db8ea64e --- /dev/null +++ b/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import ClerkExpo + +final class ClerkNativeBridgeTests: XCTestCase { + @MainActor + func testTrustedDeviceAvailabilityIsUnavailableBeforeConfiguration() async throws { + let availability = try await ClerkNativeBridge.shared.getTrustedDeviceAvailability( + id: nil, + identifierHint: nil + ) + + XCTAssertEqual(availability["isAvailable"] as? Bool, false) + XCTAssertEqual(availability["unavailableReason"] as? String, "environment_unavailable") + } + + @MainActor + func testTrustedDeviceOperationsRejectBeforeConfiguration() async { + await assertEnvironmentUnavailable { + try await ClerkNativeBridge.shared.listTrustedDevices() + } + await assertEnvironmentUnavailable { + try await ClerkNativeBridge.shared.enrollTrustedDevice( + deviceName: nil, + identifierHint: nil, + reason: nil, + policy: "biometry_or_device_passcode" + ) + } + await assertEnvironmentUnavailable { + try await ClerkNativeBridge.shared.revokeTrustedDevice(id: "td_test") + } + await assertEnvironmentUnavailable { + try await ClerkNativeBridge.shared.signInWithTrustedDevice( + id: nil, + identifierHint: nil, + reason: nil + ) + } + } + + @MainActor + private func assertEnvironmentUnavailable( + _ operation: @MainActor () async throws -> Any, + file: StaticString = #filePath, + line: UInt = #line + ) async { + do { + _ = try await operation() + XCTFail("Expected trusted-device operation to reject before configuration.", file: file, line: line) + } catch { + let descriptor = ClerkNativeBridge.trustedDeviceErrorDescriptor( + error, + fallbackCode: "unexpected_error" + ) + XCTAssertEqual(descriptor.code, "environment_unavailable", file: file, line: line) + } + } +} diff --git a/packages/expo/src/trusted-devices/errors.ts b/packages/expo/src/trusted-devices/errors.ts index 83be5a25aae..9e0c3a41174 100644 --- a/packages/expo/src/trusted-devices/errors.ts +++ b/packages/expo/src/trusted-devices/errors.ts @@ -1,4 +1,5 @@ export type TrustedDeviceErrorCode = + | 'environment_unavailable' | 'unsupported_platform' | 'biometric_authentication_unavailable' | 'biometric_authentication_canceled' From e4aff94f995a319a70c3f9710ce464ebe2d81aa2 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 13 Aug 2026 23:15:02 -0400 Subject: [PATCH 07/10] chore(repo): update repository files --- .changeset/thin-spoons-trust.md | 7 +- packages/expo/README.md | 2 +- .../expo/modules/clerk/ClerkExpoModule.kt | 55 ++++ .../modules/clerk/TrustedDeviceBridgeTest.kt | 18 +- packages/expo/ios/ClerkNativeBridge.swift | 1 + .../__tests__/useAuthViewState.test.tsx | 39 +++ packages/expo/src/native/useAuthViewState.ts | 3 +- .../ClerkProvider.nativeClientSync.test.tsx | 182 ++++++++++- .../nativeClientSyncCoordinator.test.ts | 154 ++++++++++ .../expo/src/provider/nativeClientSync.tsx | 172 +++++++---- .../provider/nativeClientSyncCoordinator.ts | 124 ++++++++ .../expo/src/specs/NativeClerkModule.types.ts | 20 +- .../__tests__/useTrustedDevices.test.ts | 289 +++++++++++++++++- packages/expo/src/trusted-devices/types.ts | 10 +- .../useTrustedDevices.shared.ts | 82 ++++- 15 files changed, 1064 insertions(+), 94 deletions(-) create mode 100644 packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts create mode 100644 packages/expo/src/provider/nativeClientSyncCoordinator.ts diff --git a/.changeset/thin-spoons-trust.md b/.changeset/thin-spoons-trust.md index 93eed769894..09695cc3d99 100644 --- a/.changeset/thin-spoons-trust.md +++ b/.changeset/thin-spoons-trust.md @@ -2,7 +2,7 @@ '@clerk/expo': minor --- -Add iOS and Android APIs for biometric trusted-device enrollment, sign-in, availability, listing, and revocation, including structured native error codes and forward-compatible resource values. Add `useAuthViewState()` for keeping a non-dismissible root `` mounted through an optional trusted-device enrollment prompt, and support configuring the Face ID permission message through the Expo config plugin. +Add iOS and Android APIs for biometric trusted-device enrollment, sign-in, availability, listing, and revocation, including structured native error codes. Trusted-device sign-in synchronizes the JS client before resolving and returns the JS sign-in resource and `setActive()` so apps can continue second-factor, new-password, or client-trust steps. Add `useAuthViewState()` for keeping a non-dismissible root `` mounted through an optional trusted-device enrollment prompt, and support configuring the Face ID permission message through the Expo config plugin. ```tsx import { useTrustedDevices } from '@clerk/expo'; @@ -25,10 +25,13 @@ export function useBiometricSignIn(identifierHint: string) { return null; } - return signIn({ + const result = await signIn({ identifierHint, reason: 'Use biometrics to sign in.', }); + + // Continue any remaining steps through result.signIn. + return result; }; return { enableBiometricSignIn, signInWithBiometrics }; diff --git a/packages/expo/README.md b/packages/expo/README.md index 3bd26b16dd5..d8b9565546d 100644 --- a/packages/expo/README.md +++ b/packages/expo/README.md @@ -52,7 +52,7 @@ For further information, guides, and examples visit the [Expo reference document Biometric trusted-device 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. -Trusted-device operations preserve Clerk API and native biometric error codes. Use `isTrustedDeviceError(error)` to safely inspect `error.code`; unrecognized codes and resource values remain available for forward compatibility. +Trusted-device operations preserve Clerk API and native biometric error codes. Use `isTrustedDeviceError(error)` to safely inspect `error.code`; unrecognized error codes remain available for forward compatibility, while unfamiliar platform and status values are normalized to `unknown`. #### Face ID on iOS 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 27f51558239..2e079eec0f0 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 @@ -75,6 +75,7 @@ internal fun trustedDevicePayload(trustedDevice: TrustedDevice): Map { return mapOf( + "id" to signIn.id, "status" to signIn.status.name.lowercase(), "createdSessionId" to signIn.createdSessionId ) @@ -94,6 +95,17 @@ internal data class TrustedDeviceBridgeError( val message: String ) +internal fun trustedDeviceEnvironmentError(isInitialized: Boolean): TrustedDeviceBridgeError? { + if (isInitialized) { + return null + } + + return TrustedDeviceBridgeError( + code = "environment_unavailable", + message = "Trusted-device operations are unavailable until Clerk finishes configuring." + ) +} + internal fun trustedDeviceKeyManagerErrorCode( code: TrustedDeviceKeyManagerException.Code ): String = code.name.lowercase() @@ -578,6 +590,10 @@ class ClerkExpoModule : Module() { } private fun listTrustedDevices(promise: Promise) { + if (!requireTrustedDeviceEnvironment(promise)) { + return + } + coroutineScope.launch { try { when (val result = Clerk.trustedDevices.list()) { @@ -607,6 +623,10 @@ class ClerkExpoModule : Module() { policy: String, promise: Promise ) { + if (!requireTrustedDeviceEnvironment(promise)) { + return + } + val trustedDevicePolicy = trustedDevicePolicy(policy) if (trustedDevicePolicy == null) { promise.reject( @@ -619,6 +639,9 @@ class ClerkExpoModule : Module() { coroutineScope.launch { try { + if (!attachCurrentActivityForTrustedDevice(promise)) { + return@launch + } when ( val result = Clerk.trustedDevices.enroll( deviceName = deviceName, @@ -647,6 +670,10 @@ class ClerkExpoModule : Module() { } private fun revokeTrustedDevice(id: String, promise: Promise) { + if (!requireTrustedDeviceEnvironment(promise)) { + return + } + coroutineScope.launch { try { when (val result = Clerk.trustedDevices.revoke(id)) { @@ -675,8 +702,15 @@ class ClerkExpoModule : Module() { reason: String?, promise: Promise ) { + if (!requireTrustedDeviceEnvironment(promise)) { + return + } + coroutineScope.launch { try { + if (!attachCurrentActivityForTrustedDevice(promise)) { + return@launch + } when ( val result = Clerk.trustedDevices.signIn( id = id, @@ -703,6 +737,27 @@ class ClerkExpoModule : Module() { } } + private fun requireTrustedDeviceEnvironment(promise: Promise): Boolean { + val error = trustedDeviceEnvironmentError(Clerk.isInitialized.value) ?: return true + promise.reject(error.code, error.message, null) + return false + } + + private fun attachCurrentActivityForTrustedDevice(promise: Promise): Boolean { + val activity = appContext.currentActivity + if (activity == null) { + promise.reject( + "environment_unavailable", + "Trusted-device authentication requires an active Android activity", + null + ) + return false + } + + Clerk.attachActivity(activity) + return true + } + private fun rejectTrustedDeviceFailure( promise: Promise, code: String, diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt b/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt index 1e69471e236..9f91b852560 100644 --- a/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt +++ b/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt @@ -13,6 +13,18 @@ import org.junit.Assert.assertNull import org.junit.Test class TrustedDeviceBridgeTest { + @Test + fun `requires Clerk initialization before trusted-device operations`() { + assertEquals( + TrustedDeviceBridgeError( + code = "environment_unavailable", + message = "Trusted-device operations are unavailable until Clerk finishes configuring." + ), + trustedDeviceEnvironmentError(isInitialized = false) + ) + assertNull(trustedDeviceEnvironmentError(isInitialized = true)) + } + @Test fun `maps trusted-device availability to the JavaScript contract`() { assertEquals( @@ -72,7 +84,11 @@ class TrustedDeviceBridgeTest { @Test fun `maps trusted-device sign-in results`() { assertEquals( - mapOf("status" to "complete", "createdSessionId" to "sess_123"), + mapOf( + "id" to "sia_123", + "status" to "complete", + "createdSessionId" to "sess_123" + ), trustedDeviceSignInPayload( SignIn( id = "sia_123", diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 711a747b9f3..c562ac2dcef 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -427,6 +427,7 @@ final class ClerkNativeBridge { ) return [ + "id": signIn.id, "status": signIn.status.rawValue, "createdSessionId": Self.bridgeValue(signIn.createdSessionId), ] diff --git a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx index 8be45d2ea99..988f2a57729 100644 --- a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx +++ b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx @@ -101,6 +101,45 @@ describe('useAuthViewState', () => { }); }); + test('resumes native tracking when a valid event arrives after falling back', async () => { + mocks.getAuthFlowState.mockResolvedValue({ isLoaded: true }); + + const { result } = renderHook(() => useAuthViewState()); + + await waitFor(() => { + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + }); + + act(() => { + mocks.listener?.({ isLoaded: true, isAuthFlowComplete: false }); + }); + + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: false }); + }); + + test('keeps native tracking when the initial snapshot rejects after a valid event', async () => { + let rejectSnapshot!: (error: Error) => void; + mocks.getAuthFlowState.mockReturnValue( + new Promise((_resolve, reject) => { + rejectSnapshot = reject; + }), + ); + + const { result } = renderHook(() => useAuthViewState()); + + act(() => { + mocks.listener?.({ isLoaded: true, isAuthFlowComplete: false }); + }); + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: false }); + + await act(async () => { + rejectSnapshot(new Error('late native failure')); + await Promise.resolve(); + }); + + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: false }); + }); + test('falls back to JS session state when the native auth-flow state rejects', async () => { const error = new Error('native failure'); vi.stubGlobal('__DEV__', true); diff --git a/packages/expo/src/native/useAuthViewState.ts b/packages/expo/src/native/useAuthViewState.ts index 4c67083db05..5a602f5c89b 100644 --- a/packages/expo/src/native/useAuthViewState.ts +++ b/packages/expo/src/native/useAuthViewState.ts @@ -67,6 +67,7 @@ export function useAuthViewState(): UseAuthViewStateReturn { didReceiveEvent = true; setNativeState(state); + setUseJsFallback(false); }); void nativeModule @@ -83,7 +84,7 @@ export function useAuthViewState(): UseAuthViewStateReturn { } }) .catch(error => { - if (!isMounted) { + if (!isMounted || didReceiveEvent) { return; } diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index d048d2d25fb..422b53a9307 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -1,9 +1,10 @@ import { act, render, waitFor } from '@testing-library/react'; -import React, { type ReactNode } from 'react'; +import React, { type ReactNode, useEffect } from 'react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CLERK_CLIENT_JWT_KEY } from '../../constants'; import { ClerkProvider } from '../ClerkProvider'; +import { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } from '../nativeClientSyncCoordinator'; const mocks = vi.hoisted(() => { return { @@ -220,6 +221,43 @@ describe('ClerkProvider native client sync', () => { expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); }); + test('registers native bootstrap before child effects can await synchronization', async () => { + const configure = deferred(); + mocks.configure.mockReturnValue(configure.promise); + let didFinishWaiting = false; + + function Child() { + useEffect(() => { + void waitForPendingJsToNativeSync().then(() => { + didFinishWaiting = true; + }); + }, []); + return null; + } + + render( + + + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + await Promise.resolve(); + expect(didFinishWaiting).toBe(false); + + act(() => { + configure.resolve(); + }); + await waitFor(() => { + expect(didFinishWaiting).toBe(true); + }); + }); + test('syncs the native device token to JS after Clerk loads during bootstrap', async () => { mocks.clerkInstance.loaded = false; mocks.clerkInstance.status = 'loading'; @@ -1430,7 +1468,7 @@ describe('ClerkProvider native client sync', () => { }); }); - test('continues processing queued native sync after a native sync failure', async () => { + test('retries failed native state while processing a queued sync', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); let rejectFirstSync: ((error: Error) => void) | undefined; mocks.syncClientStateFromJs.mockImplementationOnce(() => { @@ -1464,7 +1502,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), true, true); }); }); @@ -1534,6 +1572,144 @@ describe('ClerkProvider native client sync', () => { }); }); + test('tracks an in-flight device-token sync until native reconciliation completes', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + + const nativeSync = deferred(); + mocks.syncClientStateFromJs.mockReturnValueOnce(nativeSync.promise); + + await act(async () => { + await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'client-token'); + }); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('client-token', expect.any(String), false, true); + }); + + let didFinishWaiting = false; + const waiting = waitForPendingJsToNativeSync().then(() => { + didFinishWaiting = true; + }); + await Promise.resolve(); + expect(didFinishWaiting).toBe(false); + + nativeSync.resolve(); + await waiting; + expect(didFinishWaiting).toBe(true); + }); + + test('preserves a failed native refresh until a later refresh succeeds', async () => { + const error = new Error('native refresh failed'); + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + mocks.tokenCache.getToken.mockResolvedValue(null); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + + mocks.syncClientStateFromJs.mockRejectedValueOnce(error); + await act(async () => { + await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'failed-client-token'); + }); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('failed-client-token', expect.any(String), false, true); + }); + await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); + await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); + + mocks.syncClientStateFromJs.mockResolvedValueOnce(undefined); + await act(async () => { + await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'recovered-client-token'); + }); + + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith( + 'recovered-client-token', + expect.any(String), + false, + true, + ); + }); + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + consoleWarn.mockRestore(); + }); + + test('awaits JS session activation during explicit native-to-JS synchronization', async () => { + const activeSession = { + id: 'sess_native', + status: 'active', + user: { id: 'user_native' }, + }; + const refreshedClient = { + id: 'client_1', + signIn: { + id: 'sia_native', + status: 'complete', + createdSessionId: activeSession.id, + }, + signedInSessions: [activeSession], + lastActiveSessionId: activeSession.id, + }; + const fetchClient = vi.fn().mockResolvedValue(refreshedClient); + mocks.tokenCache.getToken.mockResolvedValue('native-client-token'); + mocks.getClientToken.mockResolvedValue('native-client-token'); + mocks.clerkInstance.client = { + id: 'client_1', + signIn: { id: '', status: null, createdSessionId: null }, + signedInSessions: [], + lastActiveSessionId: null, + fetch: fetchClient, + }; + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'native-client-token'); + }); + fetchClient.mockClear(); + + const activation = deferred(); + mocks.clerkInstance.setActive.mockReturnValueOnce(activation.promise); + let didFinishSync = false; + const sync = synchronizeNativeClientToJs().then(() => { + didFinishSync = true; + }); + + await waitFor(() => { + expect(fetchClient).toHaveBeenCalledTimes(1); + expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: activeSession }); + }); + expect(didFinishSync).toBe(false); + + activation.resolve(); + await sync; + expect(didFinishSync).toBe(true); + }); + test('ignores native client events that echo a JS-originated sync', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts new file mode 100644 index 00000000000..c347af495a1 --- /dev/null +++ b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { NativeClientEvent } from '../../hooks/useNativeClientEvents'; +import { + registerNativeToJsSyncHandler, + synchronizeNativeClientToJs, + trackPendingJsToNativeSync, + waitForPendingJsToNativeSync, +} from '../nativeClientSyncCoordinator'; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise(innerResolve => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function rejectableDeferred(): { promise: Promise; reject: (error: Error) => void } { + let reject!: (error: Error) => void; + const promise = new Promise((_resolve, innerReject) => { + reject = innerReject; + }); + return { promise, reject }; +} + +function nativeClientEvent(issuedAt: number): NativeClientEvent { + return { + issuedAt, + changed: { client: true, deviceToken: true }, + deviceToken: `native-token-${issuedAt}`, + }; +} + +let unregister: (() => void) | undefined; + +afterEach(() => { + unregister?.(); + unregister = undefined; +}); + +describe('native client sync coordinator', () => { + test('preserves a JS-to-native sync failure until a later sync succeeds', async () => { + const error = new Error('native sync failed'); + trackPendingJsToNativeSync(Promise.reject(error)); + + await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); + await expect(waitForPendingJsToNativeSync()).rejects.toBe(error); + + trackPendingJsToNativeSync(Promise.resolve()); + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + }); + + test('does not restore an older failure after a newer sync succeeds', async () => { + const olderSync = rejectableDeferred(); + trackPendingJsToNativeSync(olderSync.promise); + trackPendingJsToNativeSync(Promise.resolve()); + + olderSync.reject(new Error('stale native sync failure')); + + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + }); + + test('waits for an event sync before starting explicit synchronization', async () => { + const eventSync = deferred(); + const explicitSync = deferred(); + const handler = vi.fn((event?: NativeClientEvent | null) => (event ? eventSync.promise : explicitSync.promise)); + unregister = registerNativeToJsSyncHandler(handler); + + const fromEvent = synchronizeNativeClientToJs(nativeClientEvent(1)); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + const explicit = synchronizeNativeClientToJs(); + await Promise.resolve(); + expect(handler).toHaveBeenCalledTimes(1); + + eventSync.resolve(); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + expect(handler).toHaveBeenLastCalledWith(); + + explicitSync.resolve(); + await Promise.all([fromEvent, explicit]); + }); + + test('runs a follow-up synchronization when an event arrives during explicit synchronization', async () => { + const explicitSync = deferred(); + const followUpSync = deferred(); + const handler = vi + .fn() + .mockImplementationOnce(() => explicitSync.promise) + .mockImplementationOnce(() => followUpSync.promise); + unregister = registerNativeToJsSyncHandler(handler); + + const explicit = synchronizeNativeClientToJs(); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + const fromEvent = synchronizeNativeClientToJs(nativeClientEvent(1)); + expect(fromEvent).toBe(explicit); + + explicitSync.resolve(); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + expect(handler).toHaveBeenLastCalledWith(); + + let didFinish = false; + void fromEvent.then(() => { + didFinish = true; + }); + await Promise.resolve(); + expect(didFinish).toBe(false); + + followUpSync.resolve(); + await Promise.all([explicit, fromEvent]); + }); + + test('runs a follow-up synchronization for another explicit request', async () => { + const firstSync = deferred(); + const followUpSync = deferred(); + const handler = vi + .fn() + .mockImplementationOnce(() => firstSync.promise) + .mockImplementationOnce(() => followUpSync.promise); + unregister = registerNativeToJsSyncHandler(handler); + + const first = synchronizeNativeClientToJs(); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + const second = synchronizeNativeClientToJs(); + expect(second).toBe(first); + + firstSync.resolve(); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + + followUpSync.resolve(); + await Promise.all([first, second]); + }); + + test('allows independent native event synchronizations to overlap', async () => { + const firstSync = deferred(); + const secondSync = deferred(); + const handler = vi.fn((event?: NativeClientEvent | null) => + event?.issuedAt === 1 ? firstSync.promise : secondSync.promise, + ); + unregister = registerNativeToJsSyncHandler(handler); + + const first = synchronizeNativeClientToJs(nativeClientEvent(1)); + const second = synchronizeNativeClientToJs(nativeClientEvent(2)); + + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + + firstSync.resolve(); + secondSync.resolve(); + await Promise.all([first, second]); + }); +}); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 6eb91627a9a..e19c904df6d 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -1,5 +1,5 @@ import type { ClientJSONSnapshot, ClientResource, SignedInSessionResource } from '@clerk/shared/types'; -import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type MutableRefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Platform } from 'react-native'; import { MemoryTokenCache } from '../cache'; @@ -7,12 +7,18 @@ import type { TokenCache } from '../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../constants'; import { type NativeClientEvent, useNativeClientEvents } from '../hooks/useNativeClientEvents'; import { ClerkExpoModule as NativeClerkModule } from '../utils/native-module'; +import { + registerNativeToJsSyncHandler, + synchronizeNativeClientToJs, + trackPendingJsToNativeSync, +} from './nativeClientSyncCoordinator'; const tokenCacheReadTimeoutMs = 1_000; const nativeDeviceTokenPollIntervalMs = 100; const nativeDeviceTokenAvailabilityTimeoutMs = 3_000; const nativeClientSyncSourceIdPrefix = 'clerk-expo-js-sync'; const unauthenticatedRecoveryCooldownMs = 5_000; +const useNativeClientBootstrapEffect = Platform.OS === 'ios' || Platform.OS === 'android' ? useLayoutEffect : useEffect; export type SyncableClerkInstance = { addListener?: (listener: () => void, options?: { skipInitialEmit?: boolean }) => () => void; @@ -41,6 +47,11 @@ type NativeRefreshFromJsOptions = { didChangeDeviceToken: boolean; }; +type NativeClientSyncCompletion = { + promise: Promise; + resolve: () => void; +}; + export type NativeRefreshFromJsController = { cancel: () => void; syncDeviceTokenToNative: (deviceToken: string | null) => void; @@ -52,6 +63,14 @@ function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +function createNativeClientSyncCompletion(): NativeClientSyncCompletion { + let resolve!: () => void; + const promise = new Promise(innerResolve => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + export function useSyncableTokenCache({ suppressTokenCacheNotificationsRef, tokenCache, @@ -578,18 +597,35 @@ export function NativeClientSync({ tokenCacheListenersRef: MutableRefObject>; }): null { const isRefreshingNativeFromJsRef = useRef(false); + const nativeRefreshPromiseRef = useRef | null>(null); const pendingNativeRefreshRef = useRef(null); const pendingNativeRefreshBeforeReadyRef = useRef(null); + const pendingNativeRefreshBeforeReadyCompletionRef = useRef(null); const nativeRefreshGenerationRef = useRef(0); const lastUnauthenticatedRecoveryRef = useRef(undefined); const enabledRef = useRef(enabled); enabledRef.current = enabled; + const queueNativeRefreshBeforeReady = useCallback((options: NativeRefreshFromJsOptions) => { + pendingNativeRefreshBeforeReadyRef.current = mergePendingNativeRefreshOptions( + pendingNativeRefreshBeforeReadyRef.current, + options, + ); + if (!pendingNativeRefreshBeforeReadyCompletionRef.current) { + const completion = createNativeClientSyncCompletion(); + pendingNativeRefreshBeforeReadyCompletionRef.current = completion; + trackPendingJsToNativeSync(completion.promise); + } + }, []); + const cancelNativeRefreshFromJs = useCallback(() => { pendingNativeRefreshRef.current = null; pendingNativeRefreshBeforeReadyRef.current = null; + pendingNativeRefreshBeforeReadyCompletionRef.current?.resolve(); + pendingNativeRefreshBeforeReadyCompletionRef.current = null; nativeRefreshGenerationRef.current += 1; isRefreshingNativeFromJsRef.current = false; + nativeRefreshPromiseRef.current = null; }, []); useEffect(() => { @@ -656,11 +692,11 @@ export function NativeClientSync({ }; }, [clerkInstance, suppressJsClientChangedRef]); - const queueNativeRefreshFromJs = useCallback((options: NativeRefreshFromJsOptions): void => { + const queueNativeRefreshFromJs = useCallback((options: NativeRefreshFromJsOptions): Promise => { if (isRefreshingNativeFromJsRef.current) { pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions(pendingNativeRefreshRef.current, options); nativeRefreshGenerationRef.current += 1; - return; + return nativeRefreshPromiseRef.current ?? Promise.resolve(); } const initialGeneration = nativeRefreshGenerationRef.current + 1; @@ -690,20 +726,29 @@ export function NativeClientSync({ ); }; - let latestRunGeneration = initialGeneration; - - void (async () => { + const nativeRefreshPromise = (async () => { let pendingOptions = options; let generation = initialGeneration; + let refreshError: unknown; + let didRefreshFail = false; do { - latestRunGeneration = generation; pendingNativeRefreshRef.current = null; try { await refreshNativeFromJsClient(pendingOptions, generation); + refreshError = undefined; + didRefreshFail = false; } catch (error: unknown) { + refreshError = error; + didRefreshFail = true; if (__DEV__) { console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); } + if (pendingNativeRefreshRef.current) { + pendingNativeRefreshRef.current = mergePendingNativeRefreshOptions( + pendingOptions, + pendingNativeRefreshRef.current, + ); + } } pendingOptions = pendingNativeRefreshRef.current ?? { didChangeClient: false, @@ -714,18 +759,29 @@ export function NativeClientSync({ nativeRefreshGenerationRef.current = generation; } } while (pendingNativeRefreshRef.current !== null); - })().finally(() => { - if (latestRunGeneration === nativeRefreshGenerationRef.current || pendingNativeRefreshRef.current === null) { + + if (didRefreshFail) { + throw refreshError; + } + })(); + const finishNativeRefresh = () => { + if (nativeRefreshPromiseRef.current === nativeRefreshPromise) { isRefreshingNativeFromJsRef.current = false; + nativeRefreshPromiseRef.current = null; } - }); + }; + + nativeRefreshPromiseRef.current = nativeRefreshPromise; + void nativeRefreshPromise.then(finishNativeRefresh, finishNativeRefresh); + trackPendingJsToNativeSync(nativeRefreshPromise); + return nativeRefreshPromise; }, []); useEffect(() => { nativeRefreshFromJsControllerRef.current = { cancel: cancelNativeRefreshFromJs, syncDeviceTokenToNative: deviceToken => { - queueNativeRefreshFromJs({ + void queueNativeRefreshFromJs({ deviceToken, didChangeClient: false, didChangeDeviceToken: true, @@ -742,17 +798,26 @@ export function NativeClientSync({ useEffect(() => { if (!enabled) { - pendingNativeRefreshBeforeReadyRef.current = null; return; } if (pendingNativeRefreshBeforeReadyRef.current) { const pendingOptions = pendingNativeRefreshBeforeReadyRef.current; + const pendingCompletion = pendingNativeRefreshBeforeReadyCompletionRef.current; pendingNativeRefreshBeforeReadyRef.current = null; - queueNativeRefreshFromJs(pendingOptions); + pendingNativeRefreshBeforeReadyCompletionRef.current = null; + void queueNativeRefreshFromJs(pendingOptions).then(pendingCompletion?.resolve, pendingCompletion?.resolve); } }, [enabled, queueNativeRefreshFromJs]); + useEffect(() => { + return () => { + pendingNativeRefreshBeforeReadyRef.current = null; + pendingNativeRefreshBeforeReadyCompletionRef.current?.resolve(); + pendingNativeRefreshBeforeReadyCompletionRef.current = null; + }; + }, []); + useEffect(() => { const listener: DeviceTokenCacheListener = deviceToken => { // A rotated device token is new input for recovery, so it reopens the unauthenticated cooldown. @@ -766,15 +831,12 @@ export function NativeClientSync({ if (!enabledRef.current) { if (clerkInstance?.loaded) { - pendingNativeRefreshBeforeReadyRef.current = mergePendingNativeRefreshOptions( - pendingNativeRefreshBeforeReadyRef.current, - options, - ); + queueNativeRefreshBeforeReady(options); } return; } - queueNativeRefreshFromJs(options); + void queueNativeRefreshFromJs(options); }; const tokenCacheListeners = tokenCacheListenersRef.current; @@ -782,7 +844,7 @@ export function NativeClientSync({ return () => { tokenCacheListeners.delete(listener); }; - }, [clerkInstance, queueNativeRefreshFromJs, tokenCacheListenersRef]); + }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, tokenCacheListenersRef]); useEffect(() => { if (!clerkInstance || typeof clerkInstance.handleUnauthenticated !== 'function') { @@ -890,18 +952,15 @@ export function NativeClientSync({ if (!enabledRef.current) { if (clerkInstance.loaded) { - pendingNativeRefreshBeforeReadyRef.current = mergePendingNativeRefreshOptions( - pendingNativeRefreshBeforeReadyRef.current, - { - didChangeClient: true, - didChangeDeviceToken: false, - }, - ); + queueNativeRefreshBeforeReady({ + didChangeClient: true, + didChangeDeviceToken: false, + }); } return; } - queueNativeRefreshFromJs({ + void queueNativeRefreshFromJs({ didChangeClient: true, didChangeDeviceToken: false, }); @@ -912,7 +971,7 @@ export function NativeClientSync({ return () => { unsubscribe(); }; - }, [clerkInstance, queueNativeRefreshFromJs, suppressJsClientChangedRef]); + }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, suppressJsClientChangedRef]); return null; } @@ -980,7 +1039,7 @@ export function useNativeClientBootstrap({ const isMountedRef = useRef(true); const [readyPublishableKey, setReadyPublishableKey] = useState(null); - useEffect(() => { + useNativeClientBootstrapEffect(() => { isMountedRef.current = true; if ( @@ -1080,7 +1139,9 @@ export function useNativeClientBootstrap({ } } }; - void configureNativeClerk(); + const nativeClientBootstrap = configureNativeClerk(); + trackPendingJsToNativeSync(nativeClientBootstrap); + void nativeClientBootstrap; } return () => { @@ -1121,41 +1182,46 @@ export function useNativeClientEventSync({ const { nativeClientEvent } = useNativeClientEvents(enabled); useEffect(() => { - if (!enabled || !nativeClientEvent || !clerkInstance) { + if (!clerkInstance) { return; } - if (nativeClientEvent.sourceId?.startsWith(nativeClientSyncSourceIdPrefix)) { + return registerNativeToJsSyncHandler(async event => { + if (!isMountedRef.current) { + throw new Error('ClerkProvider was unmounted before native client synchronization completed.'); + } + + await syncNativeClientToJs({ + clerkInstance, + nativeRefreshFromJsControllerRef, + nativeClientEvent: event, + suppressJsClientChangedRef, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + }); + }, [ + clerkInstance, + isMountedRef, + nativeRefreshFromJsControllerRef, + suppressJsClientChangedRef, + suppressTokenCacheNotificationsRef, + tokenCache, + ]); + + useEffect(() => { + if (!enabled || !nativeClientEvent || nativeClientEvent.sourceId?.startsWith(nativeClientSyncSourceIdPrefix)) { return; } const syncNativeClientStateToJs = async () => { try { - if (!isMountedRef.current) { - return; - } - await syncNativeClientToJs({ - clerkInstance, - nativeRefreshFromJsControllerRef, - nativeClientEvent, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - }); + await synchronizeNativeClientToJs(nativeClientEvent); } catch (error) { console.error(`[ClerkProvider] Failed to sync native client state:`, error); } }; void syncNativeClientStateToJs(); - }, [ - enabled, - nativeClientEvent, - clerkInstance, - isMountedRef, - nativeRefreshFromJsControllerRef, - suppressJsClientChangedRef, - suppressTokenCacheNotificationsRef, - tokenCache, - ]); + }, [enabled, nativeClientEvent]); } diff --git a/packages/expo/src/provider/nativeClientSyncCoordinator.ts b/packages/expo/src/provider/nativeClientSyncCoordinator.ts new file mode 100644 index 00000000000..5e155189739 --- /dev/null +++ b/packages/expo/src/provider/nativeClientSyncCoordinator.ts @@ -0,0 +1,124 @@ +import type { NativeClientEvent } from '../hooks/useNativeClientEvents'; + +type NativeToJsSyncHandler = (nativeClientEvent?: NativeClientEvent | null) => Promise; + +type NativeToJsSyncRegistration = { + handler: NativeToJsSyncHandler; + pendingEventSyncs: Set>; + pendingExplicitSync: Promise | null; + explicitSyncRequestGeneration: number; + explicitSyncCompletedGeneration: number; +}; + +const pendingJsToNativeSyncs = new Set>(); +let jsToNativeSyncGeneration = 0; +let latestSettledJsToNativeSyncGeneration = 0; +let latestJsToNativeSyncFailure: { error: unknown; generation: number } | null = null; +let nativeToJsSyncRegistration: NativeToJsSyncRegistration | null = null; + +function removePendingSync(pendingSyncs: Set>, sync: Promise): void { + pendingSyncs.delete(sync); +} + +export function trackPendingJsToNativeSync(sync: Promise): void { + const generation = ++jsToNativeSyncGeneration; + const trackedSync = sync.then( + () => { + if (generation >= latestSettledJsToNativeSyncGeneration) { + latestSettledJsToNativeSyncGeneration = generation; + latestJsToNativeSyncFailure = null; + } + }, + error => { + if (generation >= latestSettledJsToNativeSyncGeneration) { + latestSettledJsToNativeSyncGeneration = generation; + latestJsToNativeSyncFailure = { error, generation }; + } + }, + ); + + pendingJsToNativeSyncs.add(trackedSync); + void trackedSync.then(() => pendingJsToNativeSyncs.delete(trackedSync)); +} + +export async function waitForPendingJsToNativeSync(): Promise { + while (pendingJsToNativeSyncs.size > 0) { + await Promise.all(pendingJsToNativeSyncs); + } + + if (latestJsToNativeSyncFailure) { + throw latestJsToNativeSyncFailure.error; + } +} + +export function registerNativeToJsSyncHandler(handler: NativeToJsSyncHandler): () => void { + const registration = { + handler, + pendingEventSyncs: new Set>(), + pendingExplicitSync: null, + explicitSyncRequestGeneration: 0, + explicitSyncCompletedGeneration: 0, + }; + nativeToJsSyncRegistration = registration; + + return () => { + if (nativeToJsSyncRegistration === registration) { + nativeToJsSyncRegistration = null; + } + }; +} + +export function synchronizeNativeClientToJs(nativeClientEvent?: NativeClientEvent | null): Promise { + const registration = nativeToJsSyncRegistration; + if (!registration) { + return Promise.reject(new Error('Native Clerk client synchronization is not available.')); + } + + if (nativeClientEvent) { + if (registration.pendingExplicitSync) { + registration.explicitSyncRequestGeneration += 1; + return registration.pendingExplicitSync; + } + + const sync = Promise.resolve().then(() => registration.handler(nativeClientEvent)); + registration.pendingEventSyncs.add(sync); + void sync.then( + () => removePendingSync(registration.pendingEventSyncs, sync), + () => removePendingSync(registration.pendingEventSyncs, sync), + ); + return sync; + } + + registration.explicitSyncRequestGeneration += 1; + if (registration.pendingExplicitSync) { + return registration.pendingExplicitSync; + } + + const pendingEvents = [...registration.pendingEventSyncs]; + const sync = (async () => { + await Promise.all(pendingEvents.map(pendingEvent => pendingEvent.catch(() => undefined))); + + let firstError: unknown; + let didFail = false; + while (registration.explicitSyncCompletedGeneration < registration.explicitSyncRequestGeneration) { + const generation = registration.explicitSyncRequestGeneration; + try { + await registration.handler(); + } catch (error) { + if (!didFail) { + firstError = error; + didFail = true; + } + } + registration.explicitSyncCompletedGeneration = generation; + } + + registration.pendingExplicitSync = null; + + if (didFail) { + throw firstError; + } + })(); + registration.pendingExplicitSync = sync; + return sync; +} diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts index 6929dd3cec6..13b2806c76c 100644 --- a/packages/expo/src/specs/NativeClerkModule.types.ts +++ b/packages/expo/src/specs/NativeClerkModule.types.ts @@ -1,8 +1,6 @@ -import type { - TrustedDeviceAvailability, - TrustedDevicePolicy, - TrustedDeviceSignInResult, -} from '../trusted-devices/types'; +import type { SignInStatus } from '@clerk/shared/types'; + +import type { TrustedDeviceAvailability, TrustedDevicePolicy } from '../trusted-devices/types'; export type NativeAuthFlowState = { isLoaded: boolean; @@ -16,17 +14,23 @@ export type NativeAuthFlowModule = { export type NativeTrustedDevice = { id: string; object: 'trusted_device'; - platform: 'ios' | 'android' | (string & {}); + platform: string; appIdentifier: string; name: string | null; algorithm: 'ES256' | (string & {}); - status: 'active' | 'revoked' | (string & {}); + status: string; createdAt: number; updatedAt: number; lastUsedAt: number | null; revokedAt: number | null; }; +export type NativeTrustedDeviceSignInResult = { + id: string; + status: SignInStatus | (string & {}); + createdSessionId: string | null; +}; + export type NativeTrustedDeviceModule = { getTrustedDeviceAvailability(id: string | null, identifierHint: string | null): Promise; listTrustedDevices(): Promise; @@ -41,5 +45,5 @@ export type NativeTrustedDeviceModule = { id: string | null, identifierHint: string | null, reason: string | null, - ): Promise; + ): Promise; }; diff --git a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts index 784070c23b5..51f94fe2f35 100644 --- a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts +++ b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts @@ -1,11 +1,24 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { registerNativeToJsSyncHandler, trackPendingJsToNativeSync } from '../../provider/nativeClientSyncCoordinator'; import { isTrustedDeviceError } from '../errors'; import { useTrustedDevices as useTrustedDevicesOnUnsupportedPlatform } from '../useTrustedDevices'; import { useTrustedDevices as useTrustedDevicesOnAndroid } from '../useTrustedDevices.android'; import { useTrustedDevices as useTrustedDevicesOnIos } from '../useTrustedDevices.ios'; const mocks = vi.hoisted(() => ({ + jsSignIn: { + id: 'sia_123', + status: 'complete', + createdSessionId: 'sess_123', + prepareSecondFactor: vi.fn(), + attemptSecondFactor: vi.fn(), + resetPassword: vi.fn(), + }, + jsSignedInSessions: [{ id: 'sess_123' }], + getClerkInstance: vi.fn(), + setActive: vi.fn(), + synchronizeNativeClientToJs: vi.fn(), nativeModule: { getTrustedDeviceAvailability: vi.fn(), listTrustedDevices: vi.fn(), @@ -15,6 +28,10 @@ const mocks = vi.hoisted(() => ({ }, })); +vi.mock('../../provider/singleton', () => ({ + getClerkInstance: mocks.getClerkInstance, +})); + vi.mock('../../utils/native-module', () => ({ ClerkExpoModule: mocks.nativeModule, })); @@ -39,6 +56,27 @@ const nativeTrustedDevice = { revokedAt: null, }; +let unregisterNativeToJsSyncHandler: (() => void) | undefined; + +beforeEach(() => { + unregisterNativeToJsSyncHandler = registerNativeToJsSyncHandler(mocks.synchronizeNativeClientToJs); + mocks.synchronizeNativeClientToJs.mockResolvedValue(undefined); + mocks.getClerkInstance.mockReturnValue({ + client: { signIn: mocks.jsSignIn, signedInSessions: mocks.jsSignedInSessions }, + setActive: mocks.setActive, + }); + Object.assign(mocks.jsSignIn, { + id: 'sia_123', + status: 'complete', + createdSessionId: 'sess_123', + }); + mocks.jsSignedInSessions.splice(0, mocks.jsSignedInSessions.length, { id: 'sess_123' }); +}); + +afterEach(() => { + unregisterNativeToJsSyncHandler?.(); +}); + describe('useTrustedDevices on iOS', () => { beforeEach(() => { vi.clearAllMocks(); @@ -60,6 +98,27 @@ describe('useTrustedDevices on iOS', () => { expect(availability).toEqual({ isAvailable: true, unavailableReason: null }); }); + test('waits for native client synchronization before checking availability', async () => { + let finishNativeSync!: () => void; + const nativeSync = new Promise(resolve => { + finishNativeSync = resolve; + }); + trackPendingJsToNativeSync(nativeSync); + mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({ + isAvailable: true, + unavailableReason: null, + }); + + const availability = useTrustedDevicesOnIos().getAvailability(); + await Promise.resolve(); + + expect(mocks.nativeModule.getTrustedDeviceAvailability).not.toHaveBeenCalled(); + + finishNativeSync(); + await expect(availability).resolves.toEqual({ isAvailable: true, unavailableReason: null }); + expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledTimes(1); + }); + test('lists trusted devices and converts native timestamps to dates', async () => { mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeTrustedDevice]); @@ -89,6 +148,24 @@ describe('useTrustedDevices on iOS', () => { expect(trustedDevice.revokedAt).toBeNull(); }); + test('waits for native client synchronization before listing trusted devices', async () => { + let finishNativeSync!: () => void; + const nativeSync = new Promise(resolve => { + finishNativeSync = resolve; + }); + trackPendingJsToNativeSync(nativeSync); + mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeTrustedDevice]); + + const listing = useTrustedDevicesOnIos().list(); + await Promise.resolve(); + + expect(mocks.nativeModule.listTrustedDevices).not.toHaveBeenCalled(); + + finishNativeSync(); + await expect(listing).resolves.toHaveLength(1); + expect(mocks.nativeModule.listTrustedDevices).toHaveBeenCalledTimes(1); + }); + test('returns stable operation identities', () => { expect(useTrustedDevicesOnIos()).toBe(useTrustedDevicesOnIos()); }); @@ -111,6 +188,24 @@ describe('useTrustedDevices on iOS', () => { expect(trustedDevice.createdAt).toEqual(new Date(nativeTrustedDevice.createdAt)); }); + test('waits for native client synchronization before enrollment', async () => { + let finishNativeSync!: () => void; + const nativeSync = new Promise(resolve => { + finishNativeSync = resolve; + }); + trackPendingJsToNativeSync(nativeSync); + mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeTrustedDevice); + + const enrollment = useTrustedDevicesOnIos().enroll(); + await Promise.resolve(); + + expect(mocks.nativeModule.enrollTrustedDevice).not.toHaveBeenCalled(); + + finishNativeSync(); + await expect(enrollment).resolves.toMatchObject({ id: 'td_123' }); + expect(mocks.nativeModule.enrollTrustedDevice).toHaveBeenCalledTimes(1); + }); + test('revokes a trusted device by ID', async () => { mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({ ...nativeTrustedDevice, @@ -125,8 +220,30 @@ describe('useTrustedDevices on iOS', () => { expect(trustedDevice.revokedAt).toEqual(new Date(1_700_000_300_000)); }); + test('waits for native client synchronization before revoking a trusted device', async () => { + let finishNativeSync!: () => void; + const nativeSync = new Promise(resolve => { + finishNativeSync = resolve; + }); + trackPendingJsToNativeSync(nativeSync); + mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({ + ...nativeTrustedDevice, + status: 'revoked', + }); + + const revocation = useTrustedDevicesOnIos().revoke('td_123'); + await Promise.resolve(); + + expect(mocks.nativeModule.revokeTrustedDevice).not.toHaveBeenCalled(); + + finishNativeSync(); + await expect(revocation).resolves.toMatchObject({ id: 'td_123', status: 'revoked' }); + expect(mocks.nativeModule.revokeTrustedDevice).toHaveBeenCalledWith('td_123'); + }); + test('signs in through the native one-shot trusted-device flow', async () => { mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_123', status: 'complete', createdSessionId: 'sess_123', }); @@ -141,10 +258,156 @@ describe('useTrustedDevices on iOS', () => { 'sean@example.com', 'Use Face ID to sign in.', ); - expect(result).toEqual({ status: 'complete', createdSessionId: 'sess_123' }); + expect(result).toMatchObject({ + status: 'complete', + createdSessionId: 'sess_123', + signIn: mocks.jsSignIn, + }); + expect(result.setActive).toBe(mocks.setActive); + }); + + test('accepts a completed sign-in when the synchronized client only contains its session', async () => { + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_123', + status: 'complete', + createdSessionId: 'sess_123', + }); + Object.assign(mocks.jsSignIn, { + id: '', + status: null, + createdSessionId: null, + }); + + const result = await useTrustedDevicesOnIos().signIn(); + + expect(result).toMatchObject({ + status: 'complete', + createdSessionId: 'sess_123', + signIn: mocks.jsSignIn, + }); + }); + + test('rejects a completed sign-in when its session is absent after synchronization', async () => { + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_123', + status: 'complete', + createdSessionId: 'sess_missing', + }); + + await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client.', + ); + }); + + test('waits for native client synchronization before trusted-device sign-in', async () => { + let finishNativeSync!: () => void; + const nativeSync = new Promise(resolve => { + finishNativeSync = resolve; + }); + trackPendingJsToNativeSync(nativeSync); + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_123', + status: 'complete', + createdSessionId: 'sess_123', + }); + + const signIn = useTrustedDevicesOnIos().signIn(); + await Promise.resolve(); + + expect(mocks.nativeModule.signInWithTrustedDevice).not.toHaveBeenCalled(); + + finishNativeSync(); + await expect(signIn).resolves.toMatchObject({ status: 'complete', createdSessionId: 'sess_123' }); + expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledTimes(1); }); - test('preserves forward-compatible native values', async () => { + test.each([ + ['needs_second_factor', 'prepareSecondFactor'], + ['needs_client_trust', 'attemptSecondFactor'], + ['needs_new_password', 'resetPassword'], + ] as const)('returns a continuable JS sign-in for %s', async (status, continuationMethod) => { + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_mfa', + status, + createdSessionId: null, + }); + mocks.synchronizeNativeClientToJs.mockImplementation(() => { + Object.assign(mocks.jsSignIn, { + id: 'sia_mfa', + status, + createdSessionId: null, + }); + return Promise.resolve(); + }); + + const result = await useTrustedDevicesOnIos().signIn(); + + expect(result).toMatchObject({ + status, + createdSessionId: null, + signIn: mocks.jsSignIn, + }); + expect(result.signIn[continuationMethod]).toBe(mocks.jsSignIn[continuationMethod]); + }); + + test('does not resolve a completed sign-in before native-to-JS synchronization', async () => { + let finishSync!: () => void; + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_123', + status: 'complete', + createdSessionId: 'sess_123', + }); + mocks.synchronizeNativeClientToJs.mockReturnValue( + new Promise(resolve => { + finishSync = resolve; + }), + ); + + let didResolve = false; + const signIn = useTrustedDevicesOnIos() + .signIn() + .then(result => { + didResolve = true; + return result; + }); + await vi.waitFor(() => expect(mocks.synchronizeNativeClientToJs).toHaveBeenCalled()); + expect(didResolve).toBe(false); + + finishSync(); + await expect(signIn).resolves.toMatchObject({ createdSessionId: 'sess_123' }); + }); + + test('rejects when native-to-JS synchronization returns a different sign-in attempt', async () => { + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_native', + status: 'needs_second_factor', + createdSessionId: null, + }); + Object.assign(mocks.jsSignIn, { + id: 'sia_js', + status: 'needs_second_factor', + createdSessionId: null, + }); + + await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client.', + ); + }); + + test('rejects when the Clerk JS instance is unavailable after synchronization', async () => { + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_native', + status: 'complete', + createdSessionId: 'sess_native', + }); + mocks.getClerkInstance.mockReturnValueOnce(undefined); + + await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client.', + ); + }); + + test('normalizes unknown resource values and preserves the synchronized JS sign-in status', async () => { mocks.nativeModule.listTrustedDevices.mockResolvedValue([ { ...nativeTrustedDevice, @@ -154,6 +417,12 @@ describe('useTrustedDevices on iOS', () => { }, ]); mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_future', + status: 'future_sign_in_status', + createdSessionId: null, + }); + Object.assign(mocks.jsSignIn, { + id: 'sia_future', status: 'future_sign_in_status', createdSessionId: null, }); @@ -163,9 +432,9 @@ describe('useTrustedDevices on iOS', () => { const signIn = await trustedDevices.signIn(); expect(device).toMatchObject({ - platform: 'visionos', + platform: 'unknown', algorithm: 'ES384', - status: 'pending_review', + status: 'unknown', }); expect(signIn.status).toBe('future_sign_in_status'); }); @@ -212,9 +481,16 @@ describe('useTrustedDevices on Android', () => { unavailableReason: null, }); mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_android', + status: 'complete', + createdSessionId: 'sess_android', + }); + Object.assign(mocks.jsSignIn, { + id: 'sia_android', status: 'complete', createdSessionId: 'sess_android', }); + mocks.jsSignedInSessions.splice(0, mocks.jsSignedInSessions.length, { id: 'sess_android' }); const trustedDevices = useTrustedDevicesOnAndroid(); @@ -222,9 +498,10 @@ describe('useTrustedDevices on Android', () => { isAvailable: true, unavailableReason: null, }); - await expect(trustedDevices.signIn({ reason: 'Confirm your identity to sign in.' })).resolves.toEqual({ + await expect(trustedDevices.signIn({ reason: 'Confirm your identity to sign in.' })).resolves.toMatchObject({ status: 'complete', createdSessionId: 'sess_android', + signIn: mocks.jsSignIn, }); expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledWith(null, 'sean@example.com'); expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledWith( diff --git a/packages/expo/src/trusted-devices/types.ts b/packages/expo/src/trusted-devices/types.ts index 6c9a71d2e8a..a6d8947af22 100644 --- a/packages/expo/src/trusted-devices/types.ts +++ b/packages/expo/src/trusted-devices/types.ts @@ -1,4 +1,4 @@ -import type { SignInStatus } from '@clerk/shared/types'; +import type { SetActive, SignInResource, SignInStatus } from '@clerk/shared/types'; export type TrustedDeviceUnavailableReason = | 'environment_unavailable' @@ -19,9 +19,9 @@ export type TrustedDeviceAvailability = { export type TrustedDevicePolicy = 'biometry_current_set' | 'biometry_any' | 'biometry_or_device_passcode'; -export type TrustedDevicePlatform = 'ios' | 'android' | (string & {}); +export type TrustedDevicePlatform = 'ios' | 'android' | 'unknown'; -export type TrustedDeviceStatus = 'active' | 'revoked' | (string & {}); +export type TrustedDeviceStatus = 'active' | 'revoked' | 'unknown'; export type TrustedDevice = { id: string; @@ -58,6 +58,10 @@ export type SignInWithTrustedDeviceParams = { export type TrustedDeviceSignInResult = { status: SignInStatus | (string & {}); createdSessionId: string | null; + /** The synchronized JS sign-in resource used to continue any remaining authentication steps. */ + signIn: SignInResource; + /** Activates a session after the sign-in reaches `complete`. */ + setActive: SetActive; }; export type UseTrustedDevicesReturn = { diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts index 3487f3da200..cf165e2dd64 100644 --- a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts +++ b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts @@ -1,10 +1,20 @@ +import { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } from '../provider/nativeClientSyncCoordinator'; +import { getClerkInstance } from '../provider/singleton'; import type { NativeTrustedDevice, NativeTrustedDeviceModule } from '../specs/NativeClerkModule.types'; import { errorThrower } from '../utils/errors'; import { ClerkExpoModule } from '../utils/native-module'; -import type { TrustedDevice, UseTrustedDevicesReturn } from './types'; +import type { TrustedDevice, TrustedDevicePlatform, TrustedDeviceStatus, UseTrustedDevicesReturn } from './types'; const DEFAULT_POLICY = 'biometry_or_device_passcode'; +function toTrustedDevicePlatform(platform: string): TrustedDevicePlatform { + return platform === 'ios' || platform === 'android' ? platform : 'unknown'; +} + +function toTrustedDeviceStatus(status: string): TrustedDeviceStatus { + return status === 'active' || status === 'revoked' ? status : 'unknown'; +} + function getNativeModule(): NativeTrustedDeviceModule { const nativeModule = ClerkExpoModule; @@ -26,6 +36,8 @@ function getNativeModule(): NativeTrustedDeviceModule { function toTrustedDevice(device: NativeTrustedDevice): TrustedDevice { return { ...device, + platform: toTrustedDevicePlatform(device.platform), + status: toTrustedDeviceStatus(device.status), createdAt: new Date(device.createdAt), updatedAt: new Date(device.updatedAt), lastUsedAt: device.lastUsedAt == null ? null : new Date(device.lastUsedAt), @@ -34,16 +46,21 @@ function toTrustedDevice(device: NativeTrustedDevice): TrustedDevice { } const trustedDevices: UseTrustedDevicesReturn = Object.freeze({ - getAvailability: params => - Promise.resolve().then(() => - getNativeModule().getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null), - ), + getAvailability: async params => { + const nativeModule = getNativeModule(); + await waitForPendingJsToNativeSync(); + return nativeModule.getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null); + }, list: async () => { - const devices = await getNativeModule().listTrustedDevices(); + const nativeModule = getNativeModule(); + await waitForPendingJsToNativeSync(); + const devices = await nativeModule.listTrustedDevices(); return devices.map(toTrustedDevice); }, enroll: async params => { - const device = await getNativeModule().enrollTrustedDevice( + const nativeModule = getNativeModule(); + await waitForPendingJsToNativeSync(); + const device = await nativeModule.enrollTrustedDevice( params?.deviceName ?? null, params?.identifierHint ?? null, params?.reason ?? null, @@ -52,17 +69,50 @@ const trustedDevices: UseTrustedDevicesReturn = Object.freeze({ return toTrustedDevice(device); }, revoke: async id => { - const device = await getNativeModule().revokeTrustedDevice(id); + const nativeModule = getNativeModule(); + await waitForPendingJsToNativeSync(); + const device = await nativeModule.revokeTrustedDevice(id); return toTrustedDevice(device); }, - signIn: params => - Promise.resolve().then(() => - getNativeModule().signInWithTrustedDevice( - params?.id ?? null, - params?.identifierHint ?? null, - params?.reason ?? null, - ), - ), + signIn: async params => { + const nativeModule = getNativeModule(); + await waitForPendingJsToNativeSync(); + const nativeSignIn = await nativeModule.signInWithTrustedDevice( + params?.id ?? null, + params?.identifierHint ?? null, + params?.reason ?? null, + ); + await synchronizeNativeClientToJs(); + + const clerk = getClerkInstance(); + if (!clerk) { + return errorThrower.throw('Unable to synchronize the trusted-device sign-in with the Clerk JS client.'); + } + + const client = clerk.client; + const signIn = client?.signIn; + if (!client || !signIn) { + return errorThrower.throw('Unable to synchronize the trusted-device sign-in with the Clerk JS client.'); + } + + if (nativeSignIn.status === 'complete') { + if ( + !nativeSignIn.createdSessionId || + !client.signedInSessions.some(session => session.id === nativeSignIn.createdSessionId) + ) { + return errorThrower.throw('Unable to synchronize the trusted-device sign-in with the Clerk JS client.'); + } + } else if (!signIn.id || signIn.id !== nativeSignIn.id) { + return errorThrower.throw('Unable to synchronize the trusted-device sign-in with the Clerk JS client.'); + } + + return { + status: signIn.status ?? nativeSignIn.status, + createdSessionId: signIn.createdSessionId ?? nativeSignIn.createdSessionId, + signIn, + setActive: clerk.setActive, + }; + }, }); /** From eaff3ab5422020a8a7b526aab67f5e6b9c275bc8 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 14 Aug 2026 10:32:36 -0400 Subject: [PATCH 08/10] chore(repo): update staged changes --- .../ClerkProvider.nativeClientSync.test.tsx | 7 ++- .../nativeClientSyncCoordinator.test.ts | 34 ++++++++++++- .../provider/nativeClientSyncCoordinator.ts | 41 ++++++++++++++-- .../__tests__/useTrustedDevices.test.ts | 48 +++++++++++++++++-- .../useTrustedDevices.shared.ts | 16 +++++-- 5 files changed, 133 insertions(+), 13 deletions(-) diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index 422b53a9307..9d10087e9a9 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -4,7 +4,11 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CLERK_CLIENT_JWT_KEY } from '../../constants'; import { ClerkProvider } from '../ClerkProvider'; -import { synchronizeNativeClientToJs, waitForPendingJsToNativeSync } from '../nativeClientSyncCoordinator'; +import { + __internal_resetNativeClientSyncCoordinator, + synchronizeNativeClientToJs, + waitForPendingJsToNativeSync, +} from '../nativeClientSyncCoordinator'; const mocks = vi.hoisted(() => { return { @@ -128,6 +132,7 @@ function deferred(): { promise: Promise; resolve: () => void } { describe('ClerkProvider native client sync', () => { beforeEach(() => { + __internal_resetNativeClientSyncCoordinator(); vi.clearAllMocks(); mocks.nativeClientEvent = null; mocks.configure.mockResolvedValue(undefined); diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts index c347af495a1..1187e6d4b3a 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts @@ -1,7 +1,8 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { NativeClientEvent } from '../../hooks/useNativeClientEvents'; import { + __internal_resetNativeClientSyncCoordinator, registerNativeToJsSyncHandler, synchronizeNativeClientToJs, trackPendingJsToNativeSync, @@ -34,7 +35,12 @@ function nativeClientEvent(issuedAt: number): NativeClientEvent { let unregister: (() => void) | undefined; +beforeEach(() => { + __internal_resetNativeClientSyncCoordinator(); +}); + afterEach(() => { + vi.useRealTimers(); unregister?.(); unregister = undefined; }); @@ -61,6 +67,32 @@ describe('native client sync coordinator', () => { await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); }); + test('rejects with environment unavailable when JS-to-native synchronization times out', async () => { + vi.useFakeTimers(); + const pendingSync = deferred(); + trackPendingJsToNativeSync(pendingSync.promise); + + const waiting = expect(waitForPendingJsToNativeSync()).rejects.toMatchObject({ + code: 'environment_unavailable', + message: 'Timed out waiting for the native Clerk client to synchronize.', + }); + + await vi.advanceTimersByTimeAsync(5_000); + await waiting; + pendingSync.resolve(); + }); + + test('ignores pending synchronization outcomes from before a reset', async () => { + const staleSync = rejectableDeferred(); + trackPendingJsToNativeSync(staleSync.promise); + + __internal_resetNativeClientSyncCoordinator(); + trackPendingJsToNativeSync(Promise.resolve()); + staleSync.reject(new Error('stale native sync failure')); + + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + }); + test('waits for an event sync before starting explicit synchronization', async () => { const eventSync = deferred(); const explicitSync = deferred(); diff --git a/packages/expo/src/provider/nativeClientSyncCoordinator.ts b/packages/expo/src/provider/nativeClientSyncCoordinator.ts index 5e155189739..79adb870497 100644 --- a/packages/expo/src/provider/nativeClientSyncCoordinator.ts +++ b/packages/expo/src/provider/nativeClientSyncCoordinator.ts @@ -11,26 +11,35 @@ type NativeToJsSyncRegistration = { }; const pendingJsToNativeSyncs = new Set>(); +const pendingJsToNativeSyncTimeoutMs = 5_000; let jsToNativeSyncGeneration = 0; let latestSettledJsToNativeSyncGeneration = 0; let latestJsToNativeSyncFailure: { error: unknown; generation: number } | null = null; let nativeToJsSyncRegistration: NativeToJsSyncRegistration | null = null; +let jsToNativeSyncEpoch = 0; function removePendingSync(pendingSyncs: Set>, sync: Promise): void { pendingSyncs.delete(sync); } +function createPendingJsToNativeSyncTimeoutError(): Error & { code: 'environment_unavailable' } { + return Object.assign(new Error('Timed out waiting for the native Clerk client to synchronize.'), { + code: 'environment_unavailable' as const, + }); +} + export function trackPendingJsToNativeSync(sync: Promise): void { + const epoch = jsToNativeSyncEpoch; const generation = ++jsToNativeSyncGeneration; const trackedSync = sync.then( () => { - if (generation >= latestSettledJsToNativeSyncGeneration) { + if (epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { latestSettledJsToNativeSyncGeneration = generation; latestJsToNativeSyncFailure = null; } }, error => { - if (generation >= latestSettledJsToNativeSyncGeneration) { + if (epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { latestSettledJsToNativeSyncGeneration = generation; latestJsToNativeSyncFailure = { error, generation }; } @@ -42,8 +51,25 @@ export function trackPendingJsToNativeSync(sync: Promise): void { } export async function waitForPendingJsToNativeSync(): Promise { + const deadline = Date.now() + pendingJsToNativeSyncTimeoutMs; while (pendingJsToNativeSyncs.size > 0) { - await Promise.all(pendingJsToNativeSyncs); + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw createPendingJsToNativeSyncTimeoutError(); + } + + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + reject(createPendingJsToNativeSyncTimeoutError()); + }, remainingMs); + }); + + try { + await Promise.race([Promise.all(pendingJsToNativeSyncs), timeout]); + } finally { + clearTimeout(timeoutId); + } } if (latestJsToNativeSyncFailure) { @@ -51,6 +77,15 @@ export async function waitForPendingJsToNativeSync(): Promise { } } +export function __internal_resetNativeClientSyncCoordinator(): void { + jsToNativeSyncEpoch += 1; + pendingJsToNativeSyncs.clear(); + jsToNativeSyncGeneration = 0; + latestSettledJsToNativeSyncGeneration = 0; + latestJsToNativeSyncFailure = null; + nativeToJsSyncRegistration = null; +} + export function registerNativeToJsSyncHandler(handler: NativeToJsSyncHandler): () => void { const registration = { handler, diff --git a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts index 51f94fe2f35..7b4b01737f4 100644 --- a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts +++ b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { registerNativeToJsSyncHandler, trackPendingJsToNativeSync } from '../../provider/nativeClientSyncCoordinator'; +import { + __internal_resetNativeClientSyncCoordinator, + registerNativeToJsSyncHandler, + trackPendingJsToNativeSync, +} from '../../provider/nativeClientSyncCoordinator'; import { isTrustedDeviceError } from '../errors'; import { useTrustedDevices as useTrustedDevicesOnUnsupportedPlatform } from '../useTrustedDevices'; import { useTrustedDevices as useTrustedDevicesOnAndroid } from '../useTrustedDevices.android'; @@ -59,6 +63,7 @@ const nativeTrustedDevice = { let unregisterNativeToJsSyncHandler: (() => void) | undefined; beforeEach(() => { + __internal_resetNativeClientSyncCoordinator(); unregisterNativeToJsSyncHandler = registerNativeToJsSyncHandler(mocks.synchronizeNativeClientToJs); mocks.synchronizeNativeClientToJs.mockResolvedValue(undefined); mocks.getClerkInstance.mockReturnValue({ @@ -74,6 +79,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); unregisterNativeToJsSyncHandler?.(); }); @@ -119,6 +125,24 @@ describe('useTrustedDevices on iOS', () => { expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledTimes(1); }); + test('rejects availability when native client synchronization times out', async () => { + vi.useFakeTimers(); + let finishNativeSync!: () => void; + const nativeSync = new Promise(resolve => { + finishNativeSync = resolve; + }); + trackPendingJsToNativeSync(nativeSync); + + const availability = expect(useTrustedDevicesOnIos().getAvailability()).rejects.toMatchObject({ + code: 'environment_unavailable', + }); + + await vi.advanceTimersByTimeAsync(5_000); + await availability; + expect(mocks.nativeModule.getTrustedDeviceAvailability).not.toHaveBeenCalled(); + finishNativeSync(); + }); + test('lists trusted devices and converts native timestamps to dates', async () => { mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeTrustedDevice]); @@ -295,7 +319,7 @@ describe('useTrustedDevices on iOS', () => { }); await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( - 'Unable to synchronize the trusted-device sign-in with the Clerk JS client.', + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client: the created session is missing.', ); }); @@ -390,7 +414,7 @@ describe('useTrustedDevices on iOS', () => { }); await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( - 'Unable to synchronize the trusted-device sign-in with the Clerk JS client.', + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client: the sign-in attempt does not match.', ); }); @@ -403,7 +427,23 @@ describe('useTrustedDevices on iOS', () => { mocks.getClerkInstance.mockReturnValueOnce(undefined); await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( - 'Unable to synchronize the trusted-device sign-in with the Clerk JS client.', + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client: the Clerk instance is unavailable.', + ); + }); + + test('rejects when the Clerk JS client is unavailable after synchronization', async () => { + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_native', + status: 'complete', + createdSessionId: 'sess_native', + }); + mocks.getClerkInstance.mockReturnValueOnce({ + client: undefined, + setActive: mocks.setActive, + }); + + await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client: the client sign-in resource is unavailable.', ); }); diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts index cf165e2dd64..8dfe2ab2a74 100644 --- a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts +++ b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts @@ -86,13 +86,17 @@ const trustedDevices: UseTrustedDevicesReturn = Object.freeze({ const clerk = getClerkInstance(); if (!clerk) { - return errorThrower.throw('Unable to synchronize the trusted-device sign-in with the Clerk JS client.'); + return errorThrower.throw( + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client: the Clerk instance is unavailable.', + ); } const client = clerk.client; const signIn = client?.signIn; if (!client || !signIn) { - return errorThrower.throw('Unable to synchronize the trusted-device sign-in with the Clerk JS client.'); + return errorThrower.throw( + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client: the client sign-in resource is unavailable.', + ); } if (nativeSignIn.status === 'complete') { @@ -100,10 +104,14 @@ const trustedDevices: UseTrustedDevicesReturn = Object.freeze({ !nativeSignIn.createdSessionId || !client.signedInSessions.some(session => session.id === nativeSignIn.createdSessionId) ) { - return errorThrower.throw('Unable to synchronize the trusted-device sign-in with the Clerk JS client.'); + return errorThrower.throw( + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client: the created session is missing.', + ); } } else if (!signIn.id || signIn.id !== nativeSignIn.id) { - return errorThrower.throw('Unable to synchronize the trusted-device sign-in with the Clerk JS client.'); + return errorThrower.throw( + 'Unable to synchronize the trusted-device sign-in with the Clerk JS client: the sign-in attempt does not match.', + ); } return { From a48843fdcd70634a9825df69950791a54d012f6f Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 14 Aug 2026 13:20:40 -0400 Subject: [PATCH 09/10] chore(repo): update project files --- .../ClerkProvider.nativeClientSync.test.tsx | 212 +++++++++++++++++- .../nativeClientSyncCoordinator.test.ts | 88 +++++++- .../expo/src/provider/nativeClientSync.tsx | 160 +++++++++---- .../provider/nativeClientSyncCoordinator.ts | 56 ++++- .../__tests__/useTrustedDevices.test.ts | 21 ++ .../useTrustedDevices.shared.ts | 9 +- 6 files changed, 476 insertions(+), 70 deletions(-) diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index 9d10087e9a9..47cce3c036a 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -130,6 +130,14 @@ function deferred(): { promise: Promise; resolve: () => void } { return { promise, resolve }; } +function rejectableDeferred(): { promise: Promise; reject: (error: Error) => void } { + let reject!: (error: Error) => void; + const promise = new Promise((_resolve, innerReject) => { + reject = innerReject; + }); + return { promise, reject }; +} + describe('ClerkProvider native client sync', () => { beforeEach(() => { __internal_resetNativeClientSyncCoordinator(); @@ -206,8 +214,10 @@ describe('ClerkProvider native client sync', () => { }); test('configures native once with the cached device token during StrictMode bootstrap', async () => { + const configure = deferred(); mocks.tokenCache.getToken.mockResolvedValue('client-token'); mocks.getClientToken.mockResolvedValue('client-token'); + mocks.configure.mockReturnValue(configure.promise); render( @@ -222,6 +232,17 @@ describe('ClerkProvider native client sync', () => { expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); }); expect(mocks.configure).toHaveBeenCalledTimes(1); + let didFinishWaiting = false; + const waiting = waitForPendingJsToNativeSync().then(() => { + didFinishWaiting = true; + }); + await Promise.resolve(); + expect(didFinishWaiting).toBe(false); + + act(() => { + configure.resolve(); + }); + await waiting; expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); }); @@ -367,11 +388,13 @@ describe('ClerkProvider native client sync', () => { expect(mocks.syncClientStateFromJs).toHaveBeenCalledTimes(1); }); - test('keeps synchronization enabled when native configure rejects', async () => { + test('preserves native configure failures and keeps synchronization disabled', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - mocks.configure.mockRejectedValue(new Error('native refresh failed')); + const configureError = new Error('native refresh failed'); + const firstConfigure = rejectableDeferred(); + mocks.configure.mockReturnValueOnce(firstConfigure.promise).mockRejectedValue(configureError); - render( + const { rerender } = render( { await waitFor(() => { expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); }); act(() => { mocks.clerkListener?.(); + firstConfigure.reject(configureError); }); + await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError); + expect(mocks.configure).toHaveBeenCalledTimes(2); - await waitFor(() => { - expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith(null, expect.any(String), true, false); - }); + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { client: true, deviceToken: true }, + deviceToken: 'native-client-token', + }; + rerender( + , + ); + + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); + expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); + + consoleError.mockRestore(); + }); + + test('does not wait for an obsolete native bootstrap after switching publishable keys', async () => { + const obsoleteConfigure = deferred(); + mocks.configure.mockReturnValueOnce(obsoleteConfigure.promise).mockResolvedValueOnce(undefined); + + const { rerender } = render( + , + ); + + await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null)); + + rerender( + , + ); + + await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null)); + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + expect(mocks.configure).toHaveBeenCalledTimes(2); + }); + + test('retries a transient native configure failure', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const configureError = new Error('transient native refresh failure'); + mocks.configure.mockRejectedValueOnce(configureError); + + const { rerender } = render( + , + ); + + await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2)); + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { client: true, deviceToken: true }, + deviceToken: 'native-client-token', + }; + rerender( + , + ); + + await waitFor(() => + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'), + ); + consoleError.mockRestore(); + }); + + test('disables synchronization when switching publishable keys fails to configure native', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const configureError = new Error('native key switch failed'); + + const { rerender } = render( + , + ); + + await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(1)); + await waitForPendingJsToNativeSync(); + mocks.configure.mockRejectedValue(configureError); + mocks.tokenCache.saveToken.mockClear(); + mocks.syncClientStateFromJs.mockClear(); + + rerender( + , + ); + + await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2)); + await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError); + + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { client: true, deviceToken: true }, + deviceToken: 'native-client-token', + }; + rerender( + , + ); + + expect(mocks.configure).toHaveBeenLastCalledWith('pk_test_456', null); + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); + expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); consoleError.mockRestore(); }); @@ -607,7 +749,7 @@ describe('ClerkProvider native client sync', () => { expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); }); - test('keeps token cache notifications suppressed across overlapping native token writes', async () => { + test('serializes native token writes while keeping cache notifications suppressed', async () => { mocks.tokenCache.getToken.mockResolvedValue(null); const firstSave = deferred(); @@ -664,9 +806,7 @@ describe('ClerkProvider native client sync', () => { />, ); - await waitFor(() => { - expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-2'); - }); + expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-2'); await act(async () => { firstSave.resolve(); @@ -676,6 +816,10 @@ describe('ClerkProvider native client sync', () => { expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token-2'); + }); + await act(async () => { secondSave.resolve(); await Promise.resolve(); @@ -1658,6 +1802,54 @@ describe('ClerkProvider native client sync', () => { consoleWarn.mockRestore(); }); + test('ignores a canceled native refresh that later rejects', async () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + mocks.tokenCache.getToken.mockResolvedValue(null); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + await waitForPendingJsToNativeSync(); + + const staleRefresh = rejectableDeferred(); + const staleError = new Error('canceled native refresh failed'); + mocks.syncClientStateFromJs.mockReturnValueOnce(staleRefresh.promise); + await act(async () => { + await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'stale-client-token'); + }); + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith('stale-client-token', expect.any(String), false, true); + }); + + mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); + mocks.nativeClientEvent = { + issuedAt: 1, + changed: { client: true, deviceToken: true }, + deviceToken: 'native-client-token', + }; + rerender( + , + ); + await waitFor(() => { + expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); + }); + + staleRefresh.reject(staleError); + await Promise.resolve(); + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + consoleWarn.mockRestore(); + }); + test('awaits JS session activation during explicit native-to-JS synchronization', async () => { const activeSession = { id: 'sess_native', diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts index 1187e6d4b3a..dde69d9c2f0 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts @@ -67,6 +67,17 @@ describe('native client sync coordinator', () => { await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); }); + test('ignores a tracked synchronization that is invalidated before it rejects', async () => { + const staleSync = rejectableDeferred(); + const invalidate = trackPendingJsToNativeSync(staleSync.promise); + + invalidate(); + staleSync.reject(new Error('canceled native refresh failed')); + await Promise.resolve(); + + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + }); + test('rejects with environment unavailable when JS-to-native synchronization times out', async () => { vi.useFakeTimers(); const pendingSync = deferred(); @@ -82,6 +93,29 @@ describe('native client sync coordinator', () => { pendingSync.resolve(); }); + test('honors an extended timeout for native bootstrap synchronization', async () => { + vi.useFakeTimers(); + const pendingSync = deferred(); + trackPendingJsToNativeSync(pendingSync.promise, 15_000); + + const waiting = waitForPendingJsToNativeSync(); + let didSettle = false; + void waiting.then( + () => { + didSettle = true; + }, + () => { + didSettle = true; + }, + ); + + await vi.advanceTimersByTimeAsync(5_000); + expect(didSettle).toBe(false); + + pendingSync.resolve(); + await expect(waiting).resolves.toBeUndefined(); + }); + test('ignores pending synchronization outcomes from before a reset', async () => { const staleSync = rejectableDeferred(); trackPendingJsToNativeSync(staleSync.promise); @@ -117,6 +151,11 @@ describe('native client sync coordinator', () => { test('runs a follow-up synchronization when an event arrives during explicit synchronization', async () => { const explicitSync = deferred(); const followUpSync = deferred(); + const signOutEvent: NativeClientEvent = { + issuedAt: 1, + changed: { client: true, deviceToken: true }, + deviceToken: null, + }; const handler = vi .fn() .mockImplementationOnce(() => explicitSync.promise) @@ -126,12 +165,12 @@ describe('native client sync coordinator', () => { const explicit = synchronizeNativeClientToJs(); await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - const fromEvent = synchronizeNativeClientToJs(nativeClientEvent(1)); + const fromEvent = synchronizeNativeClientToJs(signOutEvent); expect(fromEvent).toBe(explicit); explicitSync.resolve(); await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); - expect(handler).toHaveBeenLastCalledWith(); + expect(handler).toHaveBeenLastCalledWith(signOutEvent); let didFinish = false; void fromEvent.then(() => { @@ -144,6 +183,43 @@ describe('native client sync coordinator', () => { await Promise.all([explicit, fromEvent]); }); + test('merges change flags while preserving the latest event snapshot during explicit synchronization', async () => { + const explicitSync = deferred(); + const followUpSync = deferred(); + const handler = vi + .fn() + .mockImplementationOnce(() => explicitSync.promise) + .mockImplementationOnce(() => followUpSync.promise); + unregister = registerNativeToJsSyncHandler(handler); + + const explicit = synchronizeNativeClientToJs(); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + const clientEvent: NativeClientEvent = { + issuedAt: 1, + changed: { client: true, deviceToken: false }, + deviceToken: 'native-token-1', + }; + const tokenEvent: NativeClientEvent = { + issuedAt: 2, + changed: { client: false, deviceToken: true }, + deviceToken: null, + sourceId: 'native-sign-out', + }; + const fromClientEvent = synchronizeNativeClientToJs(clientEvent); + const fromTokenEvent = synchronizeNativeClientToJs(tokenEvent); + + explicitSync.resolve(); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + expect(handler).toHaveBeenLastCalledWith({ + ...tokenEvent, + changed: { client: true, deviceToken: true }, + }); + + followUpSync.resolve(); + await Promise.all([explicit, fromClientEvent, fromTokenEvent]); + }); + test('runs a follow-up synchronization for another explicit request', async () => { const firstSync = deferred(); const followUpSync = deferred(); @@ -166,7 +242,7 @@ describe('native client sync coordinator', () => { await Promise.all([first, second]); }); - test('allows independent native event synchronizations to overlap', async () => { + test('serializes native event synchronizations in arrival order', async () => { const firstSync = deferred(); const secondSync = deferred(); const handler = vi.fn((event?: NativeClientEvent | null) => @@ -177,9 +253,13 @@ describe('native client sync coordinator', () => { const first = synchronizeNativeClientToJs(nativeClientEvent(1)); const second = synchronizeNativeClientToJs(nativeClientEvent(2)); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(handler).toHaveBeenLastCalledWith(nativeClientEvent(1)); firstSync.resolve(); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + expect(handler).toHaveBeenLastCalledWith(nativeClientEvent(2)); + secondSync.resolve(); await Promise.all([first, second]); }); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index e19c904df6d..e795e797db8 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -18,6 +18,9 @@ const nativeDeviceTokenPollIntervalMs = 100; const nativeDeviceTokenAvailabilityTimeoutMs = 3_000; const nativeClientSyncSourceIdPrefix = 'clerk-expo-js-sync'; const unauthenticatedRecoveryCooldownMs = 5_000; +const nativeClientConfigurationMaxAttempts = 2; +const nativeClientConfigurationRetryDelayMs = 250; +const nativeClientBootstrapTimeoutMs = Platform.OS === 'android' ? 35_000 : 10_000; const useNativeClientBootstrapEffect = Platform.OS === 'ios' || Platform.OS === 'android' ? useLayoutEffect : useEffect; export type SyncableClerkInstance = { @@ -48,10 +51,19 @@ type NativeRefreshFromJsOptions = { }; type NativeClientSyncCompletion = { + invalidateTracking: () => void; promise: Promise; resolve: () => void; }; +type NativeClientBootstrapRegistration = { + clerkInstance: SyncableClerkInstance | null | undefined; + generation: number; + invalidateTracking: () => void; + publishableKey: string; + tokenCache: TokenCache | undefined; +}; + export type NativeRefreshFromJsController = { cancel: () => void; syncDeviceTokenToNative: (deviceToken: string | null) => void; @@ -68,7 +80,7 @@ function createNativeClientSyncCompletion(): NativeClientSyncCompletion { const promise = new Promise(innerResolve => { resolve = innerResolve; }); - return { promise, resolve }; + return { invalidateTracking: () => undefined, promise, resolve }; } export function useSyncableTokenCache({ @@ -598,6 +610,7 @@ export function NativeClientSync({ }): null { const isRefreshingNativeFromJsRef = useRef(false); const nativeRefreshPromiseRef = useRef | null>(null); + const invalidateTrackedNativeRefreshRef = useRef<(() => void) | null>(null); const pendingNativeRefreshRef = useRef(null); const pendingNativeRefreshBeforeReadyRef = useRef(null); const pendingNativeRefreshBeforeReadyCompletionRef = useRef(null); @@ -614,13 +627,16 @@ export function NativeClientSync({ if (!pendingNativeRefreshBeforeReadyCompletionRef.current) { const completion = createNativeClientSyncCompletion(); pendingNativeRefreshBeforeReadyCompletionRef.current = completion; - trackPendingJsToNativeSync(completion.promise); + completion.invalidateTracking = trackPendingJsToNativeSync(completion.promise); } }, []); const cancelNativeRefreshFromJs = useCallback(() => { + invalidateTrackedNativeRefreshRef.current?.(); + invalidateTrackedNativeRefreshRef.current = null; pendingNativeRefreshRef.current = null; pendingNativeRefreshBeforeReadyRef.current = null; + pendingNativeRefreshBeforeReadyCompletionRef.current?.invalidateTracking(); pendingNativeRefreshBeforeReadyCompletionRef.current?.resolve(); pendingNativeRefreshBeforeReadyCompletionRef.current = null; nativeRefreshGenerationRef.current += 1; @@ -768,12 +784,13 @@ export function NativeClientSync({ if (nativeRefreshPromiseRef.current === nativeRefreshPromise) { isRefreshingNativeFromJsRef.current = false; nativeRefreshPromiseRef.current = null; + invalidateTrackedNativeRefreshRef.current = null; } }; nativeRefreshPromiseRef.current = nativeRefreshPromise; void nativeRefreshPromise.then(finishNativeRefresh, finishNativeRefresh); - trackPendingJsToNativeSync(nativeRefreshPromise); + invalidateTrackedNativeRefreshRef.current = trackPendingJsToNativeSync(nativeRefreshPromise); return nativeRefreshPromise; }, []); @@ -813,6 +830,7 @@ export function NativeClientSync({ useEffect(() => { return () => { pendingNativeRefreshBeforeReadyRef.current = null; + pendingNativeRefreshBeforeReadyCompletionRef.current?.invalidateTracking(); pendingNativeRefreshBeforeReadyCompletionRef.current?.resolve(); pendingNativeRefreshBeforeReadyCompletionRef.current = null; }; @@ -1035,26 +1053,44 @@ export function useNativeClientBootstrap({ tokenCache: TokenCache | undefined; clerkInstance: SyncableClerkInstance | null | undefined; }) { - const startedPublishableKeyRef = useRef(null); + const activeBootstrapRef = useRef(null); + const bootstrapGenerationRef = useRef(0); const isMountedRef = useRef(true); const [readyPublishableKey, setReadyPublishableKey] = useState(null); useNativeClientBootstrapEffect(() => { isMountedRef.current = true; + const canBootstrap = enabled && (Platform.OS === 'ios' || Platform.OS === 'android') && Boolean(publishableKey); + const activeBootstrap = activeBootstrapRef.current; + const canReuseActiveBootstrap = + canBootstrap && + activeBootstrap?.publishableKey === publishableKey && + activeBootstrap.clerkInstance === clerkInstance && + activeBootstrap.tokenCache === tokenCache; + + if (activeBootstrap && !canReuseActiveBootstrap) { + activeBootstrap.invalidateTracking(); + activeBootstrapRef.current = null; + setReadyPublishableKey(null); + } - if ( - enabled && - (Platform.OS === 'ios' || Platform.OS === 'android') && - publishableKey && - startedPublishableKeyRef.current !== publishableKey - ) { - startedPublishableKeyRef.current = publishableKey; + if (canBootstrap && !activeBootstrapRef.current) { const configuringPublishableKey = publishableKey; + const bootstrapRegistration: NativeClientBootstrapRegistration = { + clerkInstance, + generation: ++bootstrapGenerationRef.current, + invalidateTracking: () => undefined, + publishableKey: configuringPublishableKey, + tokenCache, + }; + activeBootstrapRef.current = bootstrapRegistration; + setReadyPublishableKey(null); const isCurrentConfiguration = () => - isMountedRef.current && startedPublishableKeyRef.current === configuringPublishableKey; + isMountedRef.current && + activeBootstrapRef.current === bootstrapRegistration && + bootstrapGenerationRef.current === bootstrapRegistration.generation; const configureNativeClerk = async () => { - let didAttemptConfigure = false; try { const ClerkExpo = NativeClerkModule; @@ -1080,7 +1116,6 @@ export function useNativeClientBootstrap({ return; } - didAttemptConfigure = true; await ClerkExpo.configure(configuringPublishableKey, initialJsDeviceToken); if (!isCurrentConfiguration()) { @@ -1091,35 +1126,41 @@ export function useNativeClientBootstrap({ const currentJsDeviceToken = (await getCachedDeviceToken(tokenCache)) ?? null; const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false }); - if (!isCurrentConfiguration() || currentJsDeviceToken === nativeDeviceToken) { + if (!isCurrentConfiguration()) { return; } - if ( - !nativeDeviceToken || - (initialJsDeviceToken !== null && currentJsDeviceToken !== initialJsDeviceToken) - ) { - nativeRefreshFromJsControllerRef.current?.cancel(); - await ClerkExpo.syncClientStateFromJs( - currentJsDeviceToken, - `${nativeClientSyncSourceIdPrefix}-bootstrap`, - true, - true, - ); - } else { - await syncNativeClientToJs({ - clerkInstance, - nativeRefreshFromJsControllerRef, - nativeClientEvent: { - changed: { client: true, deviceToken: true }, - deviceToken: nativeDeviceToken, - issuedAt: Date.now(), - }, - suppressTokenCacheNotificationsRef, - tokenCache, - }); + if (currentJsDeviceToken !== nativeDeviceToken) { + if ( + !nativeDeviceToken || + (initialJsDeviceToken !== null && currentJsDeviceToken !== initialJsDeviceToken) + ) { + nativeRefreshFromJsControllerRef.current?.cancel(); + await ClerkExpo.syncClientStateFromJs( + currentJsDeviceToken, + `${nativeClientSyncSourceIdPrefix}-bootstrap`, + true, + true, + ); + } else { + await syncNativeClientToJs({ + clerkInstance, + nativeRefreshFromJsControllerRef, + nativeClientEvent: { + changed: { client: true, deviceToken: true }, + deviceToken: nativeDeviceToken, + issuedAt: Date.now(), + }, + suppressTokenCacheNotificationsRef, + tokenCache, + }); + } } } + + if (isCurrentConfiguration()) { + setReadyPublishableKey(configuringPublishableKey); + } } } catch (error) { const isNativeModuleNotFound = error instanceof Error && error.message.includes('Cannot find native module'); @@ -1133,19 +1174,52 @@ export function useNativeClientBootstrap({ } else if (__DEV__) { console.error(`[ClerkProvider] Failed to configure Clerk ${Platform.OS}:`, error); } - } finally { - if (didAttemptConfigure && isCurrentConfiguration()) { - setReadyPublishableKey(configuringPublishableKey); + throw error; + } + }; + const configureNativeClerkWithRetry = async () => { + for (let attempt = 1; attempt <= nativeClientConfigurationMaxAttempts; attempt++) { + try { + await configureNativeClerk(); + return; + } catch (error) { + const isNativeModuleNotFound = + error instanceof Error && error.message.includes('Cannot find native module'); + if ( + !isCurrentConfiguration() || + isNativeModuleNotFound || + attempt === nativeClientConfigurationMaxAttempts + ) { + if (isCurrentConfiguration()) { + nativeRefreshFromJsControllerRef.current?.cancel(); + } + throw error; + } + + await new Promise(resolve => setTimeout(resolve, nativeClientConfigurationRetryDelayMs)); + if (!isCurrentConfiguration()) { + return; + } } } }; - const nativeClientBootstrap = configureNativeClerk(); - trackPendingJsToNativeSync(nativeClientBootstrap); + const nativeClientBootstrap = configureNativeClerkWithRetry(); + bootstrapRegistration.invalidateTracking = trackPendingJsToNativeSync( + nativeClientBootstrap, + nativeClientBootstrapTimeoutMs, + ); void nativeClientBootstrap; } return () => { isMountedRef.current = false; + const bootstrapRegistration = activeBootstrapRef.current; + queueMicrotask(() => { + if (!isMountedRef.current && activeBootstrapRef.current === bootstrapRegistration) { + bootstrapRegistration?.invalidateTracking(); + activeBootstrapRef.current = null; + } + }); }; }, [ enabled, diff --git a/packages/expo/src/provider/nativeClientSyncCoordinator.ts b/packages/expo/src/provider/nativeClientSyncCoordinator.ts index 79adb870497..aa7de3b47fa 100644 --- a/packages/expo/src/provider/nativeClientSyncCoordinator.ts +++ b/packages/expo/src/provider/nativeClientSyncCoordinator.ts @@ -6,12 +6,13 @@ type NativeToJsSyncRegistration = { handler: NativeToJsSyncHandler; pendingEventSyncs: Set>; pendingExplicitSync: Promise | null; + pendingExplicitSyncEvent: NativeClientEvent | null; explicitSyncRequestGeneration: number; explicitSyncCompletedGeneration: number; }; -const pendingJsToNativeSyncs = new Set>(); -const pendingJsToNativeSyncTimeoutMs = 5_000; +const pendingJsToNativeSyncs = new Map, number>(); +const defaultPendingJsToNativeSyncTimeoutMs = 5_000; let jsToNativeSyncGeneration = 0; let latestSettledJsToNativeSyncGeneration = 0; let latestJsToNativeSyncFailure: { error: unknown; generation: number } | null = null; @@ -22,37 +23,62 @@ function removePendingSync(pendingSyncs: Set>, sync: Promise pendingSyncs.delete(sync); } +function mergeNativeClientEvents(current: NativeClientEvent | null, next: NativeClientEvent): NativeClientEvent { + if (!current) { + return next; + } + + return { + ...next, + changed: { + client: current.changed.client || next.changed.client, + deviceToken: current.changed.deviceToken || next.changed.deviceToken, + }, + }; +} + function createPendingJsToNativeSyncTimeoutError(): Error & { code: 'environment_unavailable' } { return Object.assign(new Error('Timed out waiting for the native Clerk client to synchronize.'), { code: 'environment_unavailable' as const, }); } -export function trackPendingJsToNativeSync(sync: Promise): void { +export function trackPendingJsToNativeSync( + sync: Promise, + timeoutMs = defaultPendingJsToNativeSyncTimeoutMs, +): () => void { const epoch = jsToNativeSyncEpoch; const generation = ++jsToNativeSyncGeneration; + let isInvalidated = false; const trackedSync = sync.then( () => { - if (epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { + if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { latestSettledJsToNativeSyncGeneration = generation; latestJsToNativeSyncFailure = null; } }, error => { - if (epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { + if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) { latestSettledJsToNativeSyncGeneration = generation; latestJsToNativeSyncFailure = { error, generation }; } }, ); - pendingJsToNativeSyncs.add(trackedSync); + pendingJsToNativeSyncs.set(trackedSync, timeoutMs); void trackedSync.then(() => pendingJsToNativeSyncs.delete(trackedSync)); + + return () => { + isInvalidated = true; + pendingJsToNativeSyncs.delete(trackedSync); + }; } export async function waitForPendingJsToNativeSync(): Promise { - const deadline = Date.now() + pendingJsToNativeSyncTimeoutMs; + const waitStartedAt = Date.now(); + let deadline = waitStartedAt + defaultPendingJsToNativeSyncTimeoutMs; while (pendingJsToNativeSyncs.size > 0) { + deadline = Math.max(deadline, waitStartedAt + Math.max(...pendingJsToNativeSyncs.values())); const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { throw createPendingJsToNativeSyncTimeoutError(); @@ -66,7 +92,7 @@ export async function waitForPendingJsToNativeSync(): Promise { }); try { - await Promise.race([Promise.all(pendingJsToNativeSyncs), timeout]); + await Promise.race([Promise.all(pendingJsToNativeSyncs.keys()), timeout]); } finally { clearTimeout(timeoutId); } @@ -91,6 +117,7 @@ export function registerNativeToJsSyncHandler(handler: NativeToJsSyncHandler): ( handler, pendingEventSyncs: new Set>(), pendingExplicitSync: null, + pendingExplicitSyncEvent: null, explicitSyncRequestGeneration: 0, explicitSyncCompletedGeneration: 0, }; @@ -111,11 +138,18 @@ export function synchronizeNativeClientToJs(nativeClientEvent?: NativeClientEven if (nativeClientEvent) { if (registration.pendingExplicitSync) { + registration.pendingExplicitSyncEvent = mergeNativeClientEvents( + registration.pendingExplicitSyncEvent, + nativeClientEvent, + ); registration.explicitSyncRequestGeneration += 1; return registration.pendingExplicitSync; } - const sync = Promise.resolve().then(() => registration.handler(nativeClientEvent)); + const pendingEvents = [...registration.pendingEventSyncs]; + const sync = Promise.all(pendingEvents.map(pendingEvent => pendingEvent.catch(() => undefined))).then(() => + registration.handler(nativeClientEvent), + ); registration.pendingEventSyncs.add(sync); void sync.then( () => removePendingSync(registration.pendingEventSyncs, sync), @@ -137,8 +171,10 @@ export function synchronizeNativeClientToJs(nativeClientEvent?: NativeClientEven let didFail = false; while (registration.explicitSyncCompletedGeneration < registration.explicitSyncRequestGeneration) { const generation = registration.explicitSyncRequestGeneration; + const pendingEvent = registration.pendingExplicitSyncEvent; + registration.pendingExplicitSyncEvent = null; try { - await registration.handler(); + await (pendingEvent ? registration.handler(pendingEvent) : registration.handler()); } catch (error) { if (!didFail) { firstError = error; diff --git a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts index 7b4b01737f4..7e8a3a02166 100644 --- a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts +++ b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts @@ -290,6 +290,27 @@ describe('useTrustedDevices on iOS', () => { expect(result.setActive).toBe(mocks.setActive); }); + test('keeps a completed native result authoritative when the current JS sign-in changes', async () => { + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + id: 'sia_native', + status: 'complete', + createdSessionId: 'sess_123', + }); + Object.assign(mocks.jsSignIn, { + id: 'sia_other', + status: 'needs_second_factor', + createdSessionId: 'sess_other', + }); + + const result = await useTrustedDevicesOnIos().signIn(); + + expect(result).toMatchObject({ + status: 'complete', + createdSessionId: 'sess_123', + signIn: mocks.jsSignIn, + }); + }); + test('accepts a completed sign-in when the synchronized client only contains its session', async () => { mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ id: 'sia_123', diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts index 8dfe2ab2a74..52b94d48a8e 100644 --- a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts +++ b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts @@ -99,7 +99,8 @@ const trustedDevices: UseTrustedDevicesReturn = Object.freeze({ ); } - if (nativeSignIn.status === 'complete') { + const isComplete = nativeSignIn.status === 'complete'; + if (isComplete) { if ( !nativeSignIn.createdSessionId || !client.signedInSessions.some(session => session.id === nativeSignIn.createdSessionId) @@ -115,8 +116,10 @@ const trustedDevices: UseTrustedDevicesReturn = Object.freeze({ } return { - status: signIn.status ?? nativeSignIn.status, - createdSessionId: signIn.createdSessionId ?? nativeSignIn.createdSessionId, + status: isComplete ? nativeSignIn.status : (signIn.status ?? nativeSignIn.status), + createdSessionId: isComplete + ? nativeSignIn.createdSessionId + : (signIn.createdSessionId ?? nativeSignIn.createdSessionId), signIn, setActive: clerk.setActive, }; From 6572eb20d0ea3c4c24abf626badc498fa62294ce Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 14 Aug 2026 14:17:44 -0400 Subject: [PATCH 10/10] chore(repo): apply staged updates --- .../ClerkProvider.nativeClientSync.test.tsx | 42 +++++++++++++++++++ .../expo/src/provider/nativeClientSync.tsx | 9 +--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx index 47cce3c036a..3289e535f5c 100644 --- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx @@ -456,6 +456,48 @@ describe('ClerkProvider native client sync', () => { expect(mocks.configure).toHaveBeenCalledTimes(2); }); + test('does not wait for an active native refresh after switching publishable keys', async () => { + const obsoleteRefresh = rejectableDeferred(); + const obsoleteRefreshError = new Error('obsolete native refresh failed'); + + const { rerender } = render( + , + ); + + await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null)); + await waitForPendingJsToNativeSync(); + + mocks.syncClientStateFromJs.mockReturnValueOnce(obsoleteRefresh.promise); + await act(async () => { + await mocks.clerkOptions?.tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, 'obsolete-client-token'); + }); + await waitFor(() => { + expect(mocks.syncClientStateFromJs).toHaveBeenCalledWith( + 'obsolete-client-token', + expect.any(String), + false, + true, + ); + }); + + rerender( + , + ); + + await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null)); + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + + obsoleteRefresh.reject(obsoleteRefreshError); + await Promise.resolve(); + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + }); + test('retries a transient native configure failure', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); const configureError = new Error('transient native refresh failure'); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index e795e797db8..5f40ed5287c 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -828,13 +828,8 @@ export function NativeClientSync({ }, [enabled, queueNativeRefreshFromJs]); useEffect(() => { - return () => { - pendingNativeRefreshBeforeReadyRef.current = null; - pendingNativeRefreshBeforeReadyCompletionRef.current?.invalidateTracking(); - pendingNativeRefreshBeforeReadyCompletionRef.current?.resolve(); - pendingNativeRefreshBeforeReadyCompletionRef.current = null; - }; - }, []); + return cancelNativeRefreshFromJs; + }, [cancelNativeRefreshFromJs]); useEffect(() => { const listener: DeviceTokenCacheListener = deviceToken => {