diff --git a/.changeset/thin-spoons-trust.md b/.changeset/thin-spoons-trust.md
new file mode 100644
index 00000000000..09695cc3d99
--- /dev/null
+++ b/.changeset/thin-spoons-trust.md
@@ -0,0 +1,39 @@
+---
+'@clerk/expo': minor
+---
+
+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';
+
+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;
+ }
+
+ 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 1fa4fbb447f..d8b9565546d 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 error codes remain available for forward compatibility, while unfamiliar platform and status values are normalized to `unknown`.
+
+#### 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..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
@@ -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,113 @@ 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(
+ "id" to signIn.id,
+ "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 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()
+
+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 +183,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 +208,10 @@ class ClerkExpoModule : Module() {
getClientToken(promise)
}
+ AsyncFunction("getAuthFlowState") { promise: Promise ->
+ promise.resolve(authFlowStatePayload())
+ }
+
AsyncFunction("syncClientStateFromJs") {
deviceToken: String?,
sourceId: String?,
@@ -110,6 +226,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 +278,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 +567,221 @@ 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) {
+ if (!requireTrustedDeviceEnvironment(promise)) {
+ return
+ }
+
+ 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
+ ) {
+ if (!requireTrustedDeviceEnvironment(promise)) {
+ return
+ }
+
+ val trustedDevicePolicy = trustedDevicePolicy(policy)
+ if (trustedDevicePolicy == null) {
+ promise.reject(
+ "invalid_trusted_device_policy",
+ "Invalid trusted-device policy: $policy",
+ null
+ )
+ return
+ }
+
+ coroutineScope.launch {
+ try {
+ if (!attachCurrentActivityForTrustedDevice(promise)) {
+ return@launch
+ }
+ 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) {
+ if (!requireTrustedDeviceEnvironment(promise)) {
+ return
+ }
+
+ 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
+ ) {
+ if (!requireTrustedDeviceEnvironment(promise)) {
+ return
+ }
+
+ coroutineScope.launch {
+ try {
+ if (!attachCurrentActivityForTrustedDevice(promise)) {
+ return@launch
+ }
+ 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 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,
+ 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..9f91b852560
--- /dev/null
+++ b/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt
@@ -0,0 +1,142 @@
+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 `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(
+ 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(
+ "id" to "sia_123",
+ "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..c562ac2dcef 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,207 @@ 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] {
+ guard Self.clerkConfigured else {
+ return [
+ "isAvailable": false,
+ "unavailableReason": "environment_unavailable",
+ ]
+ }
+
+ 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]] {
+ try Self.requireTrustedDeviceEnvironment()
+ 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] {
+ try Self.requireTrustedDeviceEnvironment()
+
+ 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] {
+ try Self.requireTrustedDeviceEnvironment()
+ 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] {
+ try Self.requireTrustedDeviceEnvironment()
+ let signIn = try await Clerk.shared.auth.signInWithTrustedDevice(
+ id: id,
+ identifierHint: identifierHint,
+ reason: reason
+ )
+
+ return [
+ "id": signIn.id,
+ "status": signIn.status.rawValue,
+ "createdSessionId": Self.bridgeValue(signIn.createdSessionId),
+ ]
+ }
+
+ @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,
+ "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 +672,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/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/__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/__tests__/useAuthViewState.test.tsx b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx
new file mode 100644
index 00000000000..988f2a57729
--- /dev/null
+++ b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx
@@ -0,0 +1,172 @@
+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 { useAuthViewState } from '../useAuthViewState';
+
+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('useAuthViewState', () => {
+ 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();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ test('loads and observes native auth-flow completion state', async () => {
+ const { result, unmount } = renderHook(() => useAuthViewState());
+
+ 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(() => useAuthViewState());
+
+ 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(() => useAuthViewState());
+
+ 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(() => useAuthViewState());
+
+ await waitFor(() => {
+ expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true });
+ });
+ });
+
+ 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);
+ 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/native/index.ts b/packages/expo/src/native/index.ts
index d892fb9a851..056bfa4c705 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 { 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/useAuthViewState.ts b/packages/expo/src/native/useAuthViewState.ts
new file mode 100644
index 00000000000..5a602f5c89b
--- /dev/null
+++ b/packages/expo/src/native/useAuthViewState.ts
@@ -0,0 +1,120 @@
+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 UseAuthViewStateReturn = 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 an optional trusted-device enrollment prompt are complete.
+ *
+ * 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 useAuthViewState(): UseAuthViewStateReturn {
+ 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);
+ setUseJsFallback(false);
+ });
+
+ void nativeModule
+ .getAuthFlowState()
+ .then(state => {
+ if (!isMounted || didReceiveEvent) {
+ return;
+ }
+
+ if (isNativeAuthFlowState(state)) {
+ setNativeState(state);
+ } else {
+ setUseJsFallback(true);
+ }
+ })
+ .catch(error => {
+ if (!isMounted || didReceiveEvent) {
+ return;
+ }
+
+ setUseJsFallback(true);
+ if (__DEV__) {
+ console.error('[useAuthViewState] Failed to get native auth-flow state:', error);
+ }
+ });
+ } catch (error) {
+ setUseJsFallback(true);
+ if (__DEV__) {
+ console.error('[useAuthViewState] 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/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx
index d048d2d25fb..3289e535f5c 100644
--- a/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx
+++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeClientSync.test.tsx
@@ -1,9 +1,14 @@
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 {
+ __internal_resetNativeClientSyncCoordinator,
+ synchronizeNativeClientToJs,
+ waitForPendingJsToNativeSync,
+} from '../nativeClientSyncCoordinator';
const mocks = vi.hoisted(() => {
return {
@@ -125,8 +130,17 @@ 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();
vi.clearAllMocks();
mocks.nativeClientEvent = null;
mocks.configure.mockResolvedValue(undefined);
@@ -200,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(
@@ -216,10 +232,58 @@ 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();
});
+ 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';
@@ -324,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);
+
+ 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('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(null, expect.any(String), true, false);
+ 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');
+ 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();
});
@@ -564,7 +791,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();
@@ -621,9 +848,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();
@@ -633,6 +858,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();
@@ -1430,7 +1659,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 +1693,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 +1763,192 @@ 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('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',
+ 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..dde69d9c2f0
--- /dev/null
+++ b/packages/expo/src/provider/__tests__/nativeClientSyncCoordinator.test.ts
@@ -0,0 +1,266 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import type { NativeClientEvent } from '../../hooks/useNativeClientEvents';
+import {
+ __internal_resetNativeClientSyncCoordinator,
+ 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;
+
+beforeEach(() => {
+ __internal_resetNativeClientSyncCoordinator();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ 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('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();
+ 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('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);
+
+ __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();
+ 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 signOutEvent: NativeClientEvent = {
+ issuedAt: 1,
+ changed: { client: true, deviceToken: true },
+ deviceToken: null,
+ };
+ 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(signOutEvent);
+ expect(fromEvent).toBe(explicit);
+
+ explicitSync.resolve();
+ await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
+ expect(handler).toHaveBeenLastCalledWith(signOutEvent);
+
+ let didFinish = false;
+ void fromEvent.then(() => {
+ didFinish = true;
+ });
+ await Promise.resolve();
+ expect(didFinish).toBe(false);
+
+ followUpSync.resolve();
+ 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();
+ 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('serializes native event synchronizations in arrival order', 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(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 6eb91627a9a..5f40ed5287c 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,21 @@ 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 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 = {
addListener?: (listener: () => void, options?: { skipInitialEmit?: boolean }) => () => void;
@@ -41,6 +50,20 @@ type NativeRefreshFromJsOptions = {
didChangeDeviceToken: boolean;
};
+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;
@@ -52,6 +75,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 { invalidateTracking: () => undefined, promise, resolve };
+}
+
export function useSyncableTokenCache({
suppressTokenCacheNotificationsRef,
tokenCache,
@@ -578,18 +609,39 @@ export function NativeClientSync({
tokenCacheListenersRef: MutableRefObject>;
}): 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);
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;
+ 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;
isRefreshingNativeFromJsRef.current = false;
+ nativeRefreshPromiseRef.current = null;
}, []);
useEffect(() => {
@@ -656,11 +708,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 +742,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 +775,30 @@ 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;
+ invalidateTrackedNativeRefreshRef.current = null;
}
- });
+ };
+
+ nativeRefreshPromiseRef.current = nativeRefreshPromise;
+ void nativeRefreshPromise.then(finishNativeRefresh, finishNativeRefresh);
+ invalidateTrackedNativeRefreshRef.current = trackPendingJsToNativeSync(nativeRefreshPromise);
+ return nativeRefreshPromise;
}, []);
useEffect(() => {
nativeRefreshFromJsControllerRef.current = {
cancel: cancelNativeRefreshFromJs,
syncDeviceTokenToNative: deviceToken => {
- queueNativeRefreshFromJs({
+ void queueNativeRefreshFromJs({
deviceToken,
didChangeClient: false,
didChangeDeviceToken: true,
@@ -742,17 +815,22 @@ 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 cancelNativeRefreshFromJs;
+ }, [cancelNativeRefreshFromJs]);
+
useEffect(() => {
const listener: DeviceTokenCacheListener = deviceToken => {
// A rotated device token is new input for recovery, so it reopens the unauthenticated cooldown.
@@ -766,15 +844,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 +857,7 @@ export function NativeClientSync({
return () => {
tokenCacheListeners.delete(listener);
};
- }, [clerkInstance, queueNativeRefreshFromJs, tokenCacheListenersRef]);
+ }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, tokenCacheListenersRef]);
useEffect(() => {
if (!clerkInstance || typeof clerkInstance.handleUnauthenticated !== 'function') {
@@ -890,18 +965,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 +984,7 @@ export function NativeClientSync({
return () => {
unsubscribe();
};
- }, [clerkInstance, queueNativeRefreshFromJs, suppressJsClientChangedRef]);
+ }, [clerkInstance, queueNativeRefreshBeforeReady, queueNativeRefreshFromJs, suppressJsClientChangedRef]);
return null;
}
@@ -976,26 +1048,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);
- useEffect(() => {
+ 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;
@@ -1021,7 +1111,6 @@ export function useNativeClientBootstrap({
return;
}
- didAttemptConfigure = true;
await ClerkExpo.configure(configuringPublishableKey, initialJsDeviceToken);
if (!isCurrentConfiguration()) {
@@ -1032,35 +1121,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');
@@ -1074,17 +1169,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;
+ }
}
}
};
- void configureNativeClerk();
+ 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,
@@ -1121,41 +1251,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..aa7de3b47fa
--- /dev/null
+++ b/packages/expo/src/provider/nativeClientSyncCoordinator.ts
@@ -0,0 +1,195 @@
+import type { NativeClientEvent } from '../hooks/useNativeClientEvents';
+
+type NativeToJsSyncHandler = (nativeClientEvent?: NativeClientEvent | null) => Promise;
+
+type NativeToJsSyncRegistration = {
+ handler: NativeToJsSyncHandler;
+ pendingEventSyncs: Set>;
+ pendingExplicitSync: Promise | null;
+ pendingExplicitSyncEvent: NativeClientEvent | null;
+ explicitSyncRequestGeneration: number;
+ explicitSyncCompletedGeneration: number;
+};
+
+const pendingJsToNativeSyncs = new Map, number>();
+const defaultPendingJsToNativeSyncTimeoutMs = 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 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,
+ timeoutMs = defaultPendingJsToNativeSyncTimeoutMs,
+): () => void {
+ const epoch = jsToNativeSyncEpoch;
+ const generation = ++jsToNativeSyncGeneration;
+ let isInvalidated = false;
+ const trackedSync = sync.then(
+ () => {
+ if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) {
+ latestSettledJsToNativeSyncGeneration = generation;
+ latestJsToNativeSyncFailure = null;
+ }
+ },
+ error => {
+ if (!isInvalidated && epoch === jsToNativeSyncEpoch && generation >= latestSettledJsToNativeSyncGeneration) {
+ latestSettledJsToNativeSyncGeneration = generation;
+ latestJsToNativeSyncFailure = { error, generation };
+ }
+ },
+ );
+
+ pendingJsToNativeSyncs.set(trackedSync, timeoutMs);
+ void trackedSync.then(() => pendingJsToNativeSyncs.delete(trackedSync));
+
+ return () => {
+ isInvalidated = true;
+ pendingJsToNativeSyncs.delete(trackedSync);
+ };
+}
+
+export async function waitForPendingJsToNativeSync(): Promise {
+ 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();
+ }
+
+ let timeoutId: ReturnType | undefined;
+ const timeout = new Promise((_resolve, reject) => {
+ timeoutId = setTimeout(() => {
+ reject(createPendingJsToNativeSyncTimeoutError());
+ }, remainingMs);
+ });
+
+ try {
+ await Promise.race([Promise.all(pendingJsToNativeSyncs.keys()), timeout]);
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ }
+
+ if (latestJsToNativeSyncFailure) {
+ throw latestJsToNativeSyncFailure.error;
+ }
+}
+
+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,
+ pendingEventSyncs: new Set>(),
+ pendingExplicitSync: null,
+ pendingExplicitSyncEvent: 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.pendingExplicitSyncEvent = mergeNativeClientEvents(
+ registration.pendingExplicitSyncEvent,
+ nativeClientEvent,
+ );
+ registration.explicitSyncRequestGeneration += 1;
+ return registration.pendingExplicitSync;
+ }
+
+ 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),
+ () => 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;
+ const pendingEvent = registration.pendingExplicitSyncEvent;
+ registration.pendingExplicitSyncEvent = null;
+ try {
+ await (pendingEvent ? registration.handler(pendingEvent) : 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.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..13b2806c76c
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkModule.types.ts
@@ -0,0 +1,49 @@
+import type { SignInStatus } from '@clerk/shared/types';
+
+import type { TrustedDeviceAvailability, TrustedDevicePolicy } 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: string;
+ appIdentifier: string;
+ name: string | null;
+ algorithm: 'ES256' | (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;
+ 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..7e8a3a02166
--- /dev/null
+++ b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts
@@ -0,0 +1,595 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+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';
+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(),
+ enrollTrustedDevice: vi.fn(),
+ revokeTrustedDevice: vi.fn(),
+ signInWithTrustedDevice: vi.fn(),
+ },
+}));
+
+vi.mock('../../provider/singleton', () => ({
+ getClerkInstance: mocks.getClerkInstance,
+}));
+
+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,
+};
+
+let unregisterNativeToJsSyncHandler: (() => void) | undefined;
+
+beforeEach(() => {
+ __internal_resetNativeClientSyncCoordinator();
+ 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(() => {
+ vi.useRealTimers();
+ unregisterNativeToJsSyncHandler?.();
+});
+
+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('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('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]);
+
+ 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('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('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());
+ });
+
+ 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('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,
+ 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('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',
+ });
+
+ 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).toMatchObject({
+ status: 'complete',
+ createdSessionId: 'sess_123',
+ signIn: mocks.jsSignIn,
+ });
+ 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',
+ 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: the created session is missing.',
+ );
+ });
+
+ 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.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: the sign-in attempt does not match.',
+ );
+ });
+
+ 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: 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.',
+ );
+ });
+
+ test('normalizes unknown resource values and preserves the synchronized JS sign-in status', async () => {
+ mocks.nativeModule.listTrustedDevices.mockResolvedValue([
+ {
+ ...nativeTrustedDevice,
+ platform: 'visionos',
+ algorithm: 'ES384',
+ status: 'pending_review',
+ },
+ ]);
+ 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,
+ });
+
+ const trustedDevices = useTrustedDevicesOnIos();
+ const [device] = await trustedDevices.list();
+ const signIn = await trustedDevices.signIn();
+
+ expect(device).toMatchObject({
+ platform: 'unknown',
+ algorithm: 'ES384',
+ status: 'unknown',
+ });
+ 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 });
+
+ 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 });
+ }
+ });
+});
+
+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({
+ 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();
+
+ 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.toMatchObject({
+ status: 'complete',
+ createdSessionId: 'sess_android',
+ signIn: mocks.jsSignIn,
+ });
+ 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('returns stable operation identities', () => {
+ expect(useTrustedDevicesOnUnsupportedPlatform()).toBe(useTrustedDevicesOnUnsupportedPlatform());
+ });
+
+ 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..9e0c3a41174
--- /dev/null
+++ b/packages/expo/src/trusted-devices/errors.ts
@@ -0,0 +1,29 @@
+export type TrustedDeviceErrorCode =
+ | 'environment_unavailable'
+ | '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..a6d8947af22
--- /dev/null
+++ b/packages/expo/src/trusted-devices/types.ts
@@ -0,0 +1,73 @@
+import type { SetActive, SignInResource, 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' | 'unknown';
+
+export type TrustedDeviceStatus = 'active' | 'revoked' | 'unknown';
+
+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;
+ /** 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 = {
+ 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..52b94d48a8e
--- /dev/null
+++ b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts
@@ -0,0 +1,136 @@
+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, 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;
+
+ 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,
+ 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),
+ revokedAt: device.revokedAt == null ? null : new Date(device.revokedAt),
+ };
+}
+
+const trustedDevices: UseTrustedDevicesReturn = Object.freeze({
+ getAvailability: async params => {
+ const nativeModule = getNativeModule();
+ await waitForPendingJsToNativeSync();
+ return nativeModule.getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null);
+ },
+ list: async () => {
+ const nativeModule = getNativeModule();
+ await waitForPendingJsToNativeSync();
+ const devices = await nativeModule.listTrustedDevices();
+ return devices.map(toTrustedDevice);
+ },
+ enroll: async params => {
+ const nativeModule = getNativeModule();
+ await waitForPendingJsToNativeSync();
+ const device = await nativeModule.enrollTrustedDevice(
+ params?.deviceName ?? null,
+ params?.identifierHint ?? null,
+ params?.reason ?? null,
+ params?.policy ?? DEFAULT_POLICY,
+ );
+ return toTrustedDevice(device);
+ },
+ revoke: async id => {
+ const nativeModule = getNativeModule();
+ await waitForPendingJsToNativeSync();
+ const device = await nativeModule.revokeTrustedDevice(id);
+ return toTrustedDevice(device);
+ },
+ 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: 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: the client sign-in resource is unavailable.',
+ );
+ }
+
+ const isComplete = nativeSignIn.status === 'complete';
+ if (isComplete) {
+ 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: 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: the sign-in attempt does not match.',
+ );
+ }
+
+ return {
+ status: isComplete ? nativeSignIn.status : (signIn.status ?? nativeSignIn.status),
+ createdSessionId: isComplete
+ ? nativeSignIn.createdSessionId
+ : (signIn.createdSessionId ?? nativeSignIn.createdSessionId),
+ signIn,
+ setActive: clerk.setActive,
+ };
+ },
+});
+
+/**
+ * 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 trustedDevices;
+}
diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.ts b/packages/expo/src/trusted-devices/useTrustedDevices.ts
new file mode 100644
index 00000000000..805387c9348
--- /dev/null
+++ b/packages/expo/src/trusted-devices/useTrustedDevices.ts
@@ -0,0 +1,32 @@
+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);
+}
+
+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 trustedDevices;
+}
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') {