diff --git a/.changeset/thin-spoons-trust.md b/.changeset/thin-spoons-trust.md
new file mode 100644
index 00000000000..93eed769894
--- /dev/null
+++ b/.changeset/thin-spoons-trust.md
@@ -0,0 +1,36 @@
+---
+'@clerk/expo': minor
+---
+
+Add iOS and Android APIs for biometric trusted-device enrollment, sign-in, availability, listing, and revocation, including structured native error codes and forward-compatible resource values. Add `useAuthViewState()` for keeping a non-dismissible root `` mounted through an optional trusted-device enrollment prompt, and support configuring the Face ID permission message through the Expo config plugin.
+
+```tsx
+import { useTrustedDevices } from '@clerk/expo';
+
+export function useBiometricSignIn(identifierHint: string) {
+ const { enroll, getAvailability, signIn } = useTrustedDevices();
+
+ // Call after the user completes a normal sign-in.
+ const enableBiometricSignIn = () =>
+ enroll({
+ identifierHint,
+ reason: 'Use biometrics to sign in next time.',
+ });
+
+ // Call when the user returns to sign in.
+ const signInWithBiometrics = async () => {
+ const { isAvailable } = await getAvailability({ identifierHint });
+
+ if (!isAvailable) {
+ return null;
+ }
+
+ return signIn({
+ identifierHint,
+ reason: 'Use biometrics to sign in.',
+ });
+ };
+
+ return { enableBiometricSignIn, signInWithBiometrics };
+}
+```
diff --git a/packages/expo/README.md b/packages/expo/README.md
index 1fa4fbb447f..3bd26b16dd5 100644
--- a/packages/expo/README.md
+++ b/packages/expo/README.md
@@ -48,6 +48,47 @@ You'll learn how to create an Expo application, install `@clerk/expo`, set up yo
For further information, guides, and examples visit the [Expo reference documentation](https://clerk.com/docs/references/expo/overview?utm_source=github&utm_medium=clerk_expo).
+### Biometric trusted devices
+
+Biometric trusted-device enrollment and sign-in are supported in development builds on iOS and Android. Android requires Android 9 (API 28) or later and an enrolled Class 3 biometric.
+
+Trusted-device operations preserve Clerk API and native biometric error codes. Use `isTrustedDeviceError(error)` to safely inspect `error.code`; unrecognized codes and resource values remain available for forward compatibility.
+
+#### Face ID on iOS
+
+Apps that use Face ID for trusted-device enrollment or sign-in must provide `NSFaceIDUsageDescription`. You can have the Clerk config plugin add it during prebuild:
+
+```json
+{
+ "expo": {
+ "plugins": [
+ [
+ "@clerk/expo",
+ {
+ "faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID for secure sign-in."
+ }
+ ]
+ ]
+ }
+}
+```
+
+The plugin only adds the permission when `faceIDPermission` is provided and does not overwrite `ios.infoPlist.NSFaceIDUsageDescription` if your app already defines it.
+
+You can also configure the key directly:
+
+```json
+{
+ "expo": {
+ "ios": {
+ "infoPlist": {
+ "NSFaceIDUsageDescription": "Allow $(PRODUCT_NAME) to use Face ID for secure sign-in."
+ }
+ }
+ }
+}
+```
+
## Support
For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo).
diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle
index 38576c33950..5286f4f1887 100644
--- a/packages/expo/android/build.gradle
+++ b/packages/expo/android/build.gradle
@@ -20,8 +20,8 @@ def clerkExpoVersion = clerkExpoPackageJson.version.toString()
// See: https://docs.gradle.org/current/userguide/version_catalogs.html for app-level version catalogs
ext {
kotlinxCoroutinesVersion = "1.7.3"
- clerkAndroidApiVersion = "1.0.36"
- clerkAndroidUiVersion = "1.0.36"
+ clerkAndroidApiVersion = "1.0.37"
+ clerkAndroidUiVersion = "1.0.37"
composeVersion = "1.7.0"
activityComposeVersion = "1.9.0"
lifecycleVersion = "2.8.0"
@@ -132,4 +132,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 c5718ade6ef..dbfc5cebd06 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
@@ -7,8 +7,14 @@ import androidx.compose.ui.unit.dp
import com.clerk.api.Clerk
import com.clerk.api.ClerkConfigurationOptions
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
@@ -19,12 +25,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"
@@ -36,13 +45,101 @@ private fun debugLog(tag: String, message: String) {
}
}
+internal fun trustedDeviceAvailabilityPayload(
+ availability: TrustedDeviceAvailability
+): Map {
+ return mapOf(
+ "isAvailable" to availability.isAvailable,
+ "unavailableReason" to availability.unavailableReason?.name?.lowercase()
+ )
+}
+
+internal fun trustedDevicePayload(trustedDevice: TrustedDevice): Map {
+ return mapOf(
+ "id" to trustedDevice.id,
+ "object" to "trusted_device",
+ "platform" to trustedDevice.platform.name.lowercase(),
+ "appIdentifier" to trustedDevice.appIdentifier,
+ "name" to trustedDevice.name,
+ "algorithm" to trustedDevice.algorithm,
+ "status" to trustedDevice.status.name.lowercase(),
+ "createdAt" to trustedDevice.createdAt,
+ "updatedAt" to trustedDevice.updatedAt,
+ "lastUsedAt" to trustedDevice.lastUsedAt,
+ "revokedAt" to trustedDevice.revokedAt
+ )
+}
+
+internal fun trustedDeviceSignInPayload(signIn: SignIn): Map {
+ return mapOf(
+ "status" to signIn.status.name.lowercase(),
+ "createdSessionId" to signIn.createdSessionId
+ )
+}
+
+internal fun trustedDevicePolicy(policy: String): TrustedDevicePolicy? {
+ return when (policy) {
+ "biometry_current_set" -> TrustedDevicePolicy.BIOMETRY_CURRENT_SET
+ "biometry_any" -> TrustedDevicePolicy.BIOMETRY_ANY
+ "biometry_or_device_passcode" -> TrustedDevicePolicy.BIOMETRY_OR_DEVICE_PASSCODE
+ else -> null
+ }
+}
+
+internal data class TrustedDeviceBridgeError(
+ val code: String,
+ val message: String
+)
+
+internal fun trustedDeviceKeyManagerErrorCode(
+ code: TrustedDeviceKeyManagerException.Code
+): String = code.name.lowercase()
+
+internal fun trustedDeviceBridgeError(
+ throwable: Throwable,
+ fallbackCode: String,
+ fallbackMessage: String
+): TrustedDeviceBridgeError {
+ val keyManagerError = throwable as? TrustedDeviceKeyManagerException
+ return TrustedDeviceBridgeError(
+ code = keyManagerError?.code?.let(::trustedDeviceKeyManagerErrorCode) ?: fallbackCode,
+ message = throwable.message ?: fallbackMessage
+ )
+}
+
+internal fun trustedDeviceBridgeError(
+ failure: ClerkResult.Failure,
+ fallbackCode: String,
+ fallbackMessage: String
+): TrustedDeviceBridgeError {
+ val apiError = failure.error?.errors?.firstOrNull()
+ val throwable = failure.throwable
+ val keyManagerError = throwable as? TrustedDeviceKeyManagerException
+
+ return TrustedDeviceBridgeError(
+ code = apiError?.code
+ ?: keyManagerError?.code?.let(::trustedDeviceKeyManagerErrorCode)
+ ?: fallbackCode,
+ message = apiError?.longMessage
+ ?: apiError?.message
+ ?: throwable?.message
+ ?: fallbackMessage
+ )
+}
+
class ClerkExpoModule : Module() {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
+ private var authFlowStateObserverJob: Job? = null
private var clientStateObserverJob: Job? = null
private var lastObservedClientState: ClientStateSnapshot? = null
private var jsOriginatedClientSyncDepth = 0
private var configuredPublishableKey: String? = null
+ private data class AuthFlowStateSnapshot(
+ val isLoaded: Boolean,
+ val isAuthFlowComplete: Boolean
+ )
+
private data class ClientStateSnapshot(
val client: Client?,
val deviceToken: String?
@@ -71,16 +168,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
}
@@ -93,6 +193,10 @@ class ClerkExpoModule : Module() {
getClientToken(promise)
}
+ AsyncFunction("getAuthFlowState") { promise: Promise ->
+ promise.resolve(authFlowStatePayload())
+ }
+
AsyncFunction("syncClientStateFromJs") {
deviceToken: String?,
sourceId: String?,
@@ -107,6 +211,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?
@@ -124,6 +260,37 @@ class ClerkExpoModule : Module() {
return ClerkConfigurationOptions().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
@@ -382,6 +549,178 @@ class ClerkExpoModule : Module() {
}
}
+ // MARK: - trusted devices
+
+ private fun getTrustedDeviceAvailability(
+ id: String?,
+ identifierHint: String?,
+ promise: Promise
+ ) {
+ coroutineScope.launch {
+ try {
+ val availability = Clerk.trustedDevices.availability(id, identifierHint)
+ promise.resolve(trustedDeviceAvailabilityPayload(availability))
+ } catch (e: Exception) {
+ rejectTrustedDeviceException(
+ promise = promise,
+ fallbackCode = "E_TRUSTED_DEVICE_AVAILABILITY_FAILED",
+ fallbackMessage = "Unable to determine trusted-device availability",
+ exception = e
+ )
+ }
+ }
+ }
+
+ private fun listTrustedDevices(promise: Promise) {
+ coroutineScope.launch {
+ try {
+ when (val result = Clerk.trustedDevices.list()) {
+ is ClerkResult.Success -> promise.resolve(result.value.map(::trustedDevicePayload))
+ is ClerkResult.Failure -> rejectTrustedDeviceFailure(
+ promise,
+ "E_TRUSTED_DEVICE_LIST_FAILED",
+ "Unable to list trusted devices",
+ result
+ )
+ }
+ } catch (e: Exception) {
+ rejectTrustedDeviceException(
+ promise = promise,
+ fallbackCode = "E_TRUSTED_DEVICE_LIST_FAILED",
+ fallbackMessage = "Unable to list trusted devices",
+ exception = e
+ )
+ }
+ }
+ }
+
+ private fun enrollTrustedDevice(
+ deviceName: String?,
+ identifierHint: String?,
+ reason: String?,
+ policy: String,
+ promise: Promise
+ ) {
+ val trustedDevicePolicy = trustedDevicePolicy(policy)
+ if (trustedDevicePolicy == null) {
+ promise.reject(
+ "invalid_trusted_device_policy",
+ "Invalid trusted-device policy: $policy",
+ null
+ )
+ return
+ }
+
+ coroutineScope.launch {
+ try {
+ when (
+ val result = Clerk.trustedDevices.enroll(
+ deviceName = deviceName,
+ identifierHint = identifierHint,
+ policy = trustedDevicePolicy,
+ promptSubtitle = reason
+ )
+ ) {
+ is ClerkResult.Success -> promise.resolve(trustedDevicePayload(result.value))
+ is ClerkResult.Failure -> rejectTrustedDeviceFailure(
+ promise,
+ "E_TRUSTED_DEVICE_ENROLLMENT_FAILED",
+ "Unable to enroll trusted device",
+ result
+ )
+ }
+ } catch (e: Exception) {
+ rejectTrustedDeviceException(
+ promise = promise,
+ fallbackCode = "E_TRUSTED_DEVICE_ENROLLMENT_FAILED",
+ fallbackMessage = "Unable to enroll trusted device",
+ exception = e
+ )
+ }
+ }
+ }
+
+ private fun revokeTrustedDevice(id: String, promise: Promise) {
+ coroutineScope.launch {
+ try {
+ when (val result = Clerk.trustedDevices.revoke(id)) {
+ is ClerkResult.Success -> promise.resolve(trustedDevicePayload(result.value))
+ is ClerkResult.Failure -> rejectTrustedDeviceFailure(
+ promise,
+ "E_TRUSTED_DEVICE_REVOCATION_FAILED",
+ "Unable to revoke trusted device",
+ result
+ )
+ }
+ } catch (e: Exception) {
+ rejectTrustedDeviceException(
+ promise = promise,
+ fallbackCode = "E_TRUSTED_DEVICE_REVOCATION_FAILED",
+ fallbackMessage = "Unable to revoke trusted device",
+ exception = e
+ )
+ }
+ }
+ }
+
+ private fun signInWithTrustedDevice(
+ id: String?,
+ identifierHint: String?,
+ reason: String?,
+ promise: Promise
+ ) {
+ coroutineScope.launch {
+ try {
+ when (
+ val result = Clerk.trustedDevices.signIn(
+ id = id,
+ identifierHint = identifierHint,
+ promptSubtitle = reason
+ )
+ ) {
+ is ClerkResult.Success -> promise.resolve(trustedDeviceSignInPayload(result.value))
+ is ClerkResult.Failure -> rejectTrustedDeviceFailure(
+ promise,
+ "E_TRUSTED_DEVICE_SIGN_IN_FAILED",
+ "Unable to sign in with trusted device",
+ result
+ )
+ }
+ } catch (e: Exception) {
+ rejectTrustedDeviceException(
+ promise = promise,
+ fallbackCode = "E_TRUSTED_DEVICE_SIGN_IN_FAILED",
+ fallbackMessage = "Unable to sign in with trusted device",
+ exception = e
+ )
+ }
+ }
+ }
+
+ private fun rejectTrustedDeviceFailure(
+ promise: Promise,
+ code: String,
+ fallbackMessage: String,
+ failure: ClerkResult.Failure
+ ) {
+ val error = trustedDeviceBridgeError(failure, code, fallbackMessage)
+ promise.reject(
+ error.code,
+ error.message,
+ failure.throwable
+ )
+ }
+
+ private fun rejectTrustedDeviceException(
+ promise: Promise,
+ fallbackCode: String,
+ fallbackMessage: String,
+ exception: Exception
+ ) {
+ val error = trustedDeviceBridgeError(exception, fallbackCode, fallbackMessage)
+ promise.reject(error.code, error.message, exception)
+ }
+
// MARK: - syncClientStateFromJs
private fun syncClientStateFromJs(
diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt b/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt
new file mode 100644
index 00000000000..1e69471e236
--- /dev/null
+++ b/packages/expo/android/src/test/java/expo/modules/clerk/TrustedDeviceBridgeTest.kt
@@ -0,0 +1,126 @@
+package expo.modules.clerk
+
+import com.clerk.api.network.model.error.ClerkErrorResponse
+import com.clerk.api.network.model.error.Error as ClerkAPIError
+import com.clerk.api.network.serialization.ClerkResult
+import com.clerk.api.signin.SignIn
+import com.clerk.api.trusteddevice.TrustedDevice
+import com.clerk.api.trusteddevice.TrustedDeviceAvailability
+import com.clerk.api.trusteddevice.TrustedDeviceKeyManagerException
+import com.clerk.api.trusteddevice.TrustedDevicePolicy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TrustedDeviceBridgeTest {
+ @Test
+ fun `maps trusted-device availability to the JavaScript contract`() {
+ assertEquals(
+ mapOf("isAvailable" to true, "unavailableReason" to null),
+ trustedDeviceAvailabilityPayload(TrustedDeviceAvailability.Available)
+ )
+ assertEquals(
+ mapOf(
+ "isAvailable" to false,
+ "unavailableReason" to "biometric_authentication_unavailable"
+ ),
+ trustedDeviceAvailabilityPayload(
+ TrustedDeviceAvailability.Unavailable(
+ TrustedDeviceAvailability.UnavailableReason.BIOMETRIC_AUTHENTICATION_UNAVAILABLE
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `maps trusted-device resources to the JavaScript contract`() {
+ val payload = trustedDevicePayload(
+ TrustedDevice(
+ id = "td_123",
+ platform = TrustedDevice.Platform.ANDROID,
+ appIdentifier = "com.example.app",
+ name = "Pixel",
+ status = TrustedDevice.Status.ACTIVE,
+ createdAt = 1_700_000_000_000,
+ updatedAt = 1_700_000_100_000,
+ lastUsedAt = 1_700_000_200_000
+ )
+ )
+
+ assertEquals("trusted_device", payload["object"])
+ assertEquals("android", payload["platform"])
+ assertEquals("active", payload["status"])
+ assertEquals("ES256", payload["algorithm"])
+ assertEquals(1_700_000_200_000, payload["lastUsedAt"])
+ assertNull(payload["revokedAt"])
+ }
+
+ @Test
+ fun `maps every supported authentication policy`() {
+ assertEquals(
+ TrustedDevicePolicy.BIOMETRY_CURRENT_SET,
+ trustedDevicePolicy("biometry_current_set")
+ )
+ assertEquals(TrustedDevicePolicy.BIOMETRY_ANY, trustedDevicePolicy("biometry_any"))
+ assertEquals(
+ TrustedDevicePolicy.BIOMETRY_OR_DEVICE_PASSCODE,
+ trustedDevicePolicy("biometry_or_device_passcode")
+ )
+ assertNull(trustedDevicePolicy("unsupported"))
+ }
+
+ @Test
+ fun `maps trusted-device sign-in results`() {
+ assertEquals(
+ mapOf("status" to "complete", "createdSessionId" to "sess_123"),
+ trustedDeviceSignInPayload(
+ SignIn(
+ id = "sia_123",
+ status = SignIn.Status.COMPLETE,
+ createdSessionId = "sess_123"
+ )
+ )
+ )
+ }
+
+ @Test
+ fun `preserves Clerk API error codes and detailed messages`() {
+ val failure = ClerkResult.apiFailure(
+ ClerkErrorResponse(
+ errors = listOf(
+ ClerkAPIError(
+ code = "trusted_device_not_registered",
+ message = "Trusted device not found.",
+ longMessage = "This device is no longer registered as trusted."
+ )
+ )
+ )
+ )
+
+ assertEquals(
+ TrustedDeviceBridgeError(
+ code = "trusted_device_not_registered",
+ message = "This device is no longer registered as trusted."
+ ),
+ trustedDeviceBridgeError(
+ failure = failure,
+ fallbackCode = "E_TRUSTED_DEVICE_SIGN_IN_FAILED",
+ fallbackMessage = "Unable to sign in with trusted device"
+ )
+ )
+ }
+
+ @Test
+ fun `normalizes native key-manager error codes`() {
+ assertEquals(
+ "biometric_authentication_canceled",
+ trustedDeviceKeyManagerErrorCode(
+ TrustedDeviceKeyManagerException.Code.BIOMETRIC_AUTHENTICATION_CANCELED
+ )
+ )
+ assertEquals(
+ "key_invalidated",
+ trustedDeviceKeyManagerErrorCode(TrustedDeviceKeyManagerException.Code.KEY_INVALIDATED)
+ )
+ }
+}
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
index f42f84dc347..cf4e4c40e12 100644
--- a/packages/expo/app.plugin.js
+++ b/packages/expo/app.plugin.js
@@ -172,6 +172,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.
@@ -314,12 +331,14 @@ const withClerkExpo = (config, props = {}) => {
}
config = withClerkAndroid(config);
config = withClerkKeychainService(config, props);
+ config = withClerkFaceIDPermission(config, props);
config = withClerkTheme(config, props);
return config;
};
module.exports = withClerkExpo;
module.exports._testing = {
+ withClerkFaceIDPermission,
validateThemeJson,
isPlainObject,
VALID_COLOR_KEYS,
diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec
index 4282163a18c..03cdc6c6454 100644
--- a/packages/expo/ios/ClerkExpo.podspec
+++ b/packages/expo/ios/ClerkExpo.podspec
@@ -18,7 +18,7 @@ else
end
clerk_ios_repo = 'https://github.com/clerk/clerk-ios.git'
-clerk_ios_version = '1.3.4'
+clerk_ios_requirement = { :kind => 'revision', :revision => '1026c9d6fb8183b6eb2a0f67bed25d2a5a3ce2cd' }
Pod::Spec.new do |s|
s.name = 'ClerkExpo'
@@ -44,7 +44,7 @@ Pod::Spec.new do |s|
spm_dependency(
s,
url: clerk_ios_repo,
- requirement: { :kind => 'exactVersion', :version => clerk_ios_version },
+ requirement: clerk_ios_requirement,
products: ['ClerkKit', 'ClerkKitUI']
)
else
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 1d2cf07fe3f..d82c092cb24 100644
--- a/packages/expo/ios/ClerkNativeBridge.swift
+++ b/packages/expo/ios/ClerkNativeBridge.swift
@@ -43,8 +43,23 @@ final class ClerkInlineAuthLogoState {
}
private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events")
+private var clerkNativeAuthFlowChangedEmitter: (([String: Any]?) -> Void)?
private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)?
+struct ClerkNativeErrorDescriptor {
+ let code: String
+ let message: String
+}
+
+private struct ClerkExpoTrustedDeviceError: LocalizedError {
+ let code: String
+ let message: String
+
+ var errorDescription: String? {
+ message
+ }
+}
+
private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware {
private static var hostSdkVersion: String? {
Bundle.main.object(forInfoDictionaryKey: "ClerkExpoVersion") as? String
@@ -74,6 +89,8 @@ final class ClerkNativeBridge {
private var clientObservationGeneration = 0
private var lastObservedClientState: ClientStateSnapshot?
+ private var authFlowObservationGeneration = 0
+ private var lastObservedAuthFlowState: AuthFlowStateSnapshot?
private var configurationDepth = 0
private var jsOriginatedClientSyncDepth = 0
@@ -84,6 +101,11 @@ final class ClerkNativeBridge {
let deviceToken: String?
}
+ private struct AuthFlowStateSnapshot: Equatable {
+ let isLoaded: Bool
+ let isAuthFlowComplete: Bool
+ }
+
private struct ClientStateChanges {
let client: Bool
let deviceToken: Bool
@@ -105,7 +127,10 @@ final class ClerkNativeBridge {
configurationDepth += 1
defer {
lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil
+ let authFlowState = Self.authFlowStateSnapshot()
+ lastObservedAuthFlowState = authFlowState
configurationDepth = max(0, configurationDepth - 1)
+ Self.emitAuthFlowChanged(Self.authFlowStatePayload(authFlowState))
}
loadThemes()
@@ -115,6 +140,7 @@ final class ClerkNativeBridge {
Self.clerkConfigured = true
Self.configuredPublishableKey = publishableKey
startClientObserver(reset: true)
+ startAuthFlowObserver(reset: true)
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
@@ -124,6 +150,7 @@ final class ClerkNativeBridge {
if Self.clerkConfigured {
startClientObserver()
+ startAuthFlowObserver()
let didUpdateDeviceToken = try await Self.syncTokenState(bearerToken: bearerToken)
if didUpdateDeviceToken {
await Self.waitForLoadedClient()
@@ -140,6 +167,7 @@ final class ClerkNativeBridge {
Self.configuredPublishableKey = publishableKey
Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions())
startClientObserver()
+ startAuthFlowObserver()
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
@@ -187,6 +215,60 @@ final class ClerkNativeBridge {
}
}
+ @MainActor
+ private func startAuthFlowObserver(reset: Bool = false) {
+ guard reset || authFlowObservationGeneration == 0 else {
+ return
+ }
+
+ authFlowObservationGeneration += 1
+ let generation = authFlowObservationGeneration
+ lastObservedAuthFlowState = Self.authFlowStateSnapshot()
+ observeAuthFlow(generation: generation)
+ }
+
+ @MainActor
+ private func observeAuthFlow(generation: Int) {
+ withObservationTracking {
+ _ = Self.authFlowStateSnapshot()
+ } onChange: { [weak self] in
+ Task { @MainActor [weak self] in
+ await Task.yield()
+
+ guard let self, generation == self.authFlowObservationGeneration else { return }
+
+ let newState = Self.authFlowStateSnapshot()
+ if let previousState = self.lastObservedAuthFlowState, newState != previousState {
+ self.lastObservedAuthFlowState = newState
+ if self.configurationDepth == 0 {
+ Self.emitAuthFlowChanged(Self.authFlowStatePayload(newState))
+ }
+ }
+
+ self.observeAuthFlow(generation: generation)
+ }
+ }
+ }
+
+ @MainActor
+ private static func authFlowStateSnapshot() -> AuthFlowStateSnapshot {
+ guard clerkConfigured else {
+ return AuthFlowStateSnapshot(isLoaded: false, isAuthFlowComplete: false)
+ }
+
+ return AuthFlowStateSnapshot(
+ isLoaded: Clerk.shared.isLoaded,
+ isAuthFlowComplete: Clerk.shared.isAuthFlowComplete
+ )
+ }
+
+ private static func authFlowStatePayload(_ state: AuthFlowStateSnapshot) -> [String: Any] {
+ [
+ "isLoaded": state.isLoaded,
+ "isAuthFlowComplete": state.isAuthFlowComplete,
+ ]
+ }
+
@MainActor
private static func clientStateSnapshot() -> ClientStateSnapshot {
let client = Clerk.shared.client
@@ -264,6 +346,184 @@ final class ClerkNativeBridge {
return Clerk.shared.deviceToken
}
+ @MainActor
+ func getAuthFlowState() -> [String: Any] {
+ Self.authFlowStatePayload(Self.authFlowStateSnapshot())
+ }
+
+ // MARK: - Trusted devices
+
+ @MainActor
+ func getTrustedDeviceAvailability(id: String?, identifierHint: String?) async throws -> [String: Any] {
+ let availability = try await Clerk.shared.trustedDevices.availability(
+ id: id,
+ identifierHint: identifierHint
+ )
+
+ return [
+ "isAvailable": availability.isAvailable,
+ "unavailableReason": availability.unavailableReason
+ .map(Self.trustedDeviceUnavailableReason) ?? NSNull(),
+ ]
+ }
+
+ @MainActor
+ func listTrustedDevices() async throws -> [[String: Any]] {
+ let trustedDevices = try await Clerk.shared.trustedDevices.list()
+ return trustedDevices.map(Self.trustedDevicePayload)
+ }
+
+ @MainActor
+ func enrollTrustedDevice(
+ deviceName: String?,
+ identifierHint: String?,
+ reason: String?,
+ policy: String
+ ) async throws -> [String: Any] {
+ guard let trustedDevicePolicy = TrustedDevicePolicy(rawValue: policy) else {
+ throw ClerkExpoTrustedDeviceError(
+ code: "invalid_trusted_device_policy",
+ message: "Invalid trusted-device policy: \(policy)."
+ )
+ }
+
+ let trustedDevice = try await Clerk.shared.trustedDevices.enroll(
+ deviceName: deviceName,
+ identifierHint: identifierHint,
+ reason: reason,
+ policy: trustedDevicePolicy
+ )
+ return Self.trustedDevicePayload(trustedDevice)
+ }
+
+ @MainActor
+ func revokeTrustedDevice(id: String) async throws -> [String: Any] {
+ let trustedDevice = try await Clerk.shared.trustedDevices.revoke(id: id)
+ return Self.trustedDevicePayload(trustedDevice)
+ }
+
+ @MainActor
+ func signInWithTrustedDevice(
+ id: String?,
+ identifierHint: String?,
+ reason: String?
+ ) async throws -> [String: Any] {
+ let signIn = try await Clerk.shared.auth.signInWithTrustedDevice(
+ id: id,
+ identifierHint: identifierHint,
+ reason: reason
+ )
+
+ return [
+ "status": signIn.status.rawValue,
+ "createdSessionId": Self.bridgeValue(signIn.createdSessionId),
+ ]
+ }
+
+ private static func trustedDevicePayload(_ trustedDevice: TrustedDevice) -> [String: Any] {
+ [
+ "id": trustedDevice.id,
+ "object": trustedDevice.object,
+ "platform": trustedDevice.platform.rawValue,
+ "appIdentifier": trustedDevice.appIdentifier,
+ "name": bridgeValue(trustedDevice.name),
+ "algorithm": trustedDevice.algorithm.rawValue,
+ "status": trustedDevice.status.rawValue,
+ "createdAt": millisecondsSince1970(trustedDevice.createdAt),
+ "updatedAt": millisecondsSince1970(trustedDevice.updatedAt),
+ "lastUsedAt": optionalMillisecondsSince1970(trustedDevice.lastUsedAt),
+ "revokedAt": optionalMillisecondsSince1970(trustedDevice.revokedAt),
+ ]
+ }
+
+ private static func trustedDeviceUnavailableReason(
+ _ reason: TrustedDeviceAvailability.UnavailableReason
+ ) -> String {
+ snakeCase(reason.rawValue)
+ }
+
+ static func trustedDeviceErrorDescriptor(
+ _ error: Error,
+ fallbackCode: String
+ ) -> ClerkNativeErrorDescriptor {
+ if let error = error as? ClerkExpoTrustedDeviceError {
+ return ClerkNativeErrorDescriptor(code: error.code, message: error.localizedDescription)
+ }
+
+ if let error = error as? ClerkAPIError {
+ return ClerkNativeErrorDescriptor(code: error.code, message: error.localizedDescription)
+ }
+
+ if let error = error as? TrustedDeviceKeyManagerError {
+ return ClerkNativeErrorDescriptor(
+ code: trustedDeviceKeyManagerErrorCode(error),
+ message: error.localizedDescription
+ )
+ }
+
+ return ClerkNativeErrorDescriptor(code: fallbackCode, message: error.localizedDescription)
+ }
+
+ private static func trustedDeviceKeyManagerErrorCode(
+ _ error: TrustedDeviceKeyManagerError
+ ) -> String {
+ switch error {
+ case .unsupportedPlatform:
+ "unsupported_platform"
+ case .biometricAuthenticationUnavailable:
+ "biometric_authentication_unavailable"
+ case .biometricAuthenticationCanceled:
+ "biometric_authentication_canceled"
+ case .biometricAuthenticationFailed:
+ "biometric_authentication_failed"
+ case .keyGenerationFailed:
+ "key_generation_failed"
+ case .keyNotFound:
+ "key_not_found"
+ case .invalidPublicKey:
+ "invalid_public_key"
+ case .publicKeyExportFailed:
+ "public_key_export_failed"
+ case .unsupportedAlgorithm:
+ "unsupported_algorithm"
+ case .signingFailed:
+ "signing_failed"
+ case .deletionFailed:
+ "key_deletion_failed"
+ @unknown default:
+ "trusted_device_key_manager_error"
+ }
+ }
+
+ private static func snakeCase(_ value: String) -> String {
+ value
+ .replacingOccurrences(
+ of: "([A-Z]+)([A-Z][a-z])",
+ with: "$1_$2",
+ options: .regularExpression
+ )
+ .replacingOccurrences(
+ of: "([a-z0-9])([A-Z])",
+ with: "$1_$2",
+ options: .regularExpression
+ )
+ .lowercased()
+ }
+
+ private static func millisecondsSince1970(_ date: Date) -> Double {
+ date.timeIntervalSince1970 * 1_000
+ }
+
+ private static func optionalMillisecondsSince1970(_ date: Date?) -> Any {
+ guard let date else { return NSNull() }
+ return millisecondsSince1970(date)
+ }
+
+ private static func bridgeValue(_ value: Value?) -> Any {
+ guard let value else { return NSNull() }
+ return value
+ }
+
// MARK: - Inline View Creation
func makeAuthViewController(
@@ -385,6 +645,19 @@ final class ClerkNativeBridge {
}
}
+ static func setAuthFlowChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) {
+ clerkNativeClientEventQueue.sync {
+ clerkNativeAuthFlowChangedEmitter = emitter
+ }
+ }
+
+ static func emitAuthFlowChanged(_ body: [String: Any]? = nil) {
+ let emitter = clerkNativeClientEventQueue.sync {
+ clerkNativeAuthFlowChangedEmitter
+ }
+ emitter?(body)
+ }
+
/// Requests that ClerkProvider reload the JS client from native client state.
static func emitClientChanged(_ body: [String: Any]? = nil) {
let emitter = clerkNativeClientEventQueue.sync {
diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo/src/__tests__/appPlugin.theme.test.js
index 24abc304e74..fcff7577b0e 100644
--- a/packages/expo/src/__tests__/appPlugin.theme.test.js
+++ b/packages/expo/src/__tests__/appPlugin.theme.test.js
@@ -1,7 +1,55 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
-const { validateThemeJson } = require('../../app.plugin.js')._testing;
+const clerkPlugin = require('../../app.plugin.js');
+const { withClerkFaceIDPermission, validateThemeJson } = clerkPlugin._testing;
+
+function applyInfoPlistMod(config, modResults) {
+ return config.mods.ios.infoPlist({
+ ...config,
+ modRequest: {},
+ modResults,
+ });
+}
+
+describe('withClerkFaceIDPermission', () => {
+ test('adds the configured Face ID usage description', async () => {
+ const config = withClerkFaceIDPermission(
+ { name: 'test', slug: 'test' },
+ { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' },
+ );
+
+ const result = await applyInfoPlistMod(config, {});
+
+ expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.');
+ });
+
+ test('preserves an app-provided Face ID usage description', async () => {
+ const config = withClerkFaceIDPermission(
+ { name: 'test', slug: 'test' },
+ { faceIDPermission: 'Clerk-provided description' },
+ );
+
+ const result = await applyInfoPlistMod(config, {
+ NSFaceIDUsageDescription: 'App-provided description',
+ });
+
+ expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description');
+ });
+
+ test('does not configure the Info.plist without an explicit permission description', () => {
+ const config = { name: 'test', slug: 'test' };
+
+ expect(withClerkFaceIDPermission(config)).toBe(config);
+ expect(config).not.toHaveProperty('mods');
+ });
+
+ test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => {
+ expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow(
+ 'faceIDPermission must be a non-empty string',
+ );
+ });
+});
describe('validateThemeJson', () => {
beforeEach(() => {
diff --git a/packages/expo/src/index.ts b/packages/expo/src/index.ts
index 8974c7371d6..e826626e21f 100644
--- a/packages/expo/src/index.ts
+++ b/packages/expo/src/index.ts
@@ -13,6 +13,7 @@ export { getClerkInstance } from './provider/singleton';
export * from './provider/ClerkProvider';
export * from './hooks';
export * from './components';
+export * from './trusted-devices';
// Override Clerk React error thrower to show that errors come from @clerk/expo
setErrorThrowerOptions({ packageName: PACKAGE_NAME });
diff --git a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx
new file mode 100644
index 00000000000..c3cb37730f3
--- /dev/null
+++ b/packages/expo/src/native/__tests__/useAuthViewState.test.tsx
@@ -0,0 +1,101 @@
+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();
+ });
+
+ 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 });
+ });
+ });
+});
diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts
index b59a8eeb106..b1c3bdf38cf 100644
--- a/packages/expo/src/native/index.ts
+++ b/packages/expo/src/native/index.ts
@@ -30,6 +30,8 @@
export { AuthView } from './AuthView';
export type { AuthViewProps, AuthViewMode } from './AuthView.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..4c67083db05
--- /dev/null
+++ b/packages/expo/src/native/useAuthViewState.ts
@@ -0,0 +1,119 @@
+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);
+ });
+
+ void nativeModule
+ .getAuthFlowState()
+ .then(state => {
+ if (!isMounted || didReceiveEvent) {
+ return;
+ }
+
+ if (isNativeAuthFlowState(state)) {
+ setNativeState(state);
+ } else {
+ setUseJsFallback(true);
+ }
+ })
+ .catch(error => {
+ if (!isMounted) {
+ 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/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts
index cced94ad12a..d43321ad832 100644
--- a/packages/expo/src/specs/NativeClerkModule.android.ts
+++ b/packages/expo/src/specs/NativeClerkModule.android.ts
@@ -1,6 +1,8 @@
import { requireOptionalNativeModule } from 'expo';
-interface Spec {
+import type { NativeAuthFlowModule, NativeTrustedDeviceModule } from './NativeClerkModule.types';
+
+interface Spec extends NativeAuthFlowModule, NativeTrustedDeviceModule {
// Exposed by Expo Modules EventEmitter for internal native client change events.
// This is not part of the public @clerk/expo API.
addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts
index c8eb967e84d..0388eb0d121 100644
--- a/packages/expo/src/specs/NativeClerkModule.ts
+++ b/packages/expo/src/specs/NativeClerkModule.ts
@@ -1,6 +1,8 @@
import { requireOptionalNativeModule } from 'expo';
-export interface Spec {
+import type { NativeAuthFlowModule, NativeTrustedDeviceModule } from './NativeClerkModule.types';
+
+export interface Spec extends NativeAuthFlowModule, NativeTrustedDeviceModule {
// Exposed by Expo Modules EventEmitter for internal native client change events.
// This is not part of the public @clerk/expo API.
addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts
new file mode 100644
index 00000000000..6929dd3cec6
--- /dev/null
+++ b/packages/expo/src/specs/NativeClerkModule.types.ts
@@ -0,0 +1,45 @@
+import type {
+ TrustedDeviceAvailability,
+ TrustedDevicePolicy,
+ TrustedDeviceSignInResult,
+} from '../trusted-devices/types';
+
+export type NativeAuthFlowState = {
+ isLoaded: boolean;
+ isAuthFlowComplete: boolean;
+};
+
+export type NativeAuthFlowModule = {
+ getAuthFlowState(): Promise;
+};
+
+export type NativeTrustedDevice = {
+ id: string;
+ object: 'trusted_device';
+ platform: 'ios' | 'android' | (string & {});
+ appIdentifier: string;
+ name: string | null;
+ algorithm: 'ES256' | (string & {});
+ status: 'active' | 'revoked' | (string & {});
+ createdAt: number;
+ updatedAt: number;
+ lastUsedAt: number | null;
+ revokedAt: number | null;
+};
+
+export type NativeTrustedDeviceModule = {
+ getTrustedDeviceAvailability(id: string | null, identifierHint: string | null): Promise;
+ listTrustedDevices(): Promise;
+ enrollTrustedDevice(
+ deviceName: string | null,
+ identifierHint: string | null,
+ reason: string | null,
+ policy: TrustedDevicePolicy,
+ ): Promise;
+ revokeTrustedDevice(id: string): Promise;
+ signInWithTrustedDevice(
+ id: string | null,
+ identifierHint: string | null,
+ reason: string | null,
+ ): Promise;
+};
diff --git a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts
new file mode 100644
index 00000000000..61c85186653
--- /dev/null
+++ b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts
@@ -0,0 +1,232 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { isTrustedDeviceError } from '../errors';
+import { useTrustedDevices as useTrustedDevicesOnUnsupportedPlatform } from '../useTrustedDevices';
+import { useTrustedDevices as useTrustedDevicesOnAndroid } from '../useTrustedDevices.android';
+import { useTrustedDevices as useTrustedDevicesOnIos } from '../useTrustedDevices.ios';
+
+const mocks = vi.hoisted(() => ({
+ nativeModule: {
+ getTrustedDeviceAvailability: vi.fn(),
+ listTrustedDevices: vi.fn(),
+ enrollTrustedDevice: vi.fn(),
+ revokeTrustedDevice: vi.fn(),
+ signInWithTrustedDevice: vi.fn(),
+ },
+}));
+
+vi.mock('../../utils/native-module', () => ({
+ ClerkExpoModule: mocks.nativeModule,
+}));
+
+vi.mock('react-native', () => ({
+ Platform: {
+ OS: 'ios',
+ },
+}));
+
+const nativeTrustedDevice = {
+ id: 'td_123',
+ object: 'trusted_device' as const,
+ platform: 'ios' as const,
+ appIdentifier: 'com.example.app',
+ name: "Sean's iPhone",
+ algorithm: 'ES256' as const,
+ status: 'active' as const,
+ createdAt: 1_700_000_000_000,
+ updatedAt: 1_700_000_100_000,
+ lastUsedAt: 1_700_000_200_000,
+ revokedAt: null,
+};
+
+describe('useTrustedDevices on iOS', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('checks availability for an optional credential selector', async () => {
+ mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({
+ isAvailable: true,
+ unavailableReason: null,
+ });
+
+ const trustedDevices = useTrustedDevicesOnIos();
+ const availability = await trustedDevices.getAvailability({
+ id: 'td_123',
+ identifierHint: 'sean@example.com',
+ });
+
+ expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledWith('td_123', 'sean@example.com');
+ expect(availability).toEqual({ isAvailable: true, unavailableReason: null });
+ });
+
+ test('lists trusted devices and converts native timestamps to dates', async () => {
+ mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeTrustedDevice]);
+
+ const [trustedDevice] = await useTrustedDevicesOnIos().list();
+
+ expect(trustedDevice).toEqual({
+ ...nativeTrustedDevice,
+ createdAt: new Date(nativeTrustedDevice.createdAt),
+ updatedAt: new Date(nativeTrustedDevice.updatedAt),
+ lastUsedAt: new Date(nativeTrustedDevice.lastUsedAt),
+ revokedAt: null,
+ });
+ });
+
+ test('enrolls with the safe default authentication policy', async () => {
+ mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeTrustedDevice);
+
+ const trustedDevice = await useTrustedDevicesOnIos().enroll({
+ deviceName: "Sean's iPhone",
+ identifierHint: 'sean@example.com',
+ reason: 'Use Face ID to trust this device.',
+ });
+
+ expect(mocks.nativeModule.enrollTrustedDevice).toHaveBeenCalledWith(
+ "Sean's iPhone",
+ 'sean@example.com',
+ 'Use Face ID to trust this device.',
+ 'biometry_or_device_passcode',
+ );
+ expect(trustedDevice.createdAt).toEqual(new Date(nativeTrustedDevice.createdAt));
+ });
+
+ test('revokes a trusted device by ID', async () => {
+ mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({
+ ...nativeTrustedDevice,
+ status: 'revoked',
+ revokedAt: 1_700_000_300_000,
+ });
+
+ const trustedDevice = await useTrustedDevicesOnIos().revoke('td_123');
+
+ expect(mocks.nativeModule.revokeTrustedDevice).toHaveBeenCalledWith('td_123');
+ expect(trustedDevice.status).toBe('revoked');
+ expect(trustedDevice.revokedAt).toEqual(new Date(1_700_000_300_000));
+ });
+
+ test('signs in through the native one-shot trusted-device flow', async () => {
+ mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({
+ status: 'complete',
+ createdSessionId: 'sess_123',
+ });
+
+ const result = await useTrustedDevicesOnIos().signIn({
+ identifierHint: 'sean@example.com',
+ reason: 'Use Face ID to sign in.',
+ });
+
+ expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledWith(
+ null,
+ 'sean@example.com',
+ 'Use Face ID to sign in.',
+ );
+ expect(result).toEqual({ status: 'complete', createdSessionId: 'sess_123' });
+ });
+
+ test('preserves forward-compatible native values', async () => {
+ mocks.nativeModule.listTrustedDevices.mockResolvedValue([
+ {
+ ...nativeTrustedDevice,
+ platform: 'visionos',
+ algorithm: 'ES384',
+ status: 'pending_review',
+ },
+ ]);
+ mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({
+ status: 'future_sign_in_status',
+ createdSessionId: null,
+ });
+
+ const trustedDevices = useTrustedDevicesOnIos();
+ const [device] = await trustedDevices.list();
+ const signIn = await trustedDevices.signIn();
+
+ expect(device).toMatchObject({
+ platform: 'visionos',
+ algorithm: 'ES384',
+ status: 'pending_review',
+ });
+ expect(signIn.status).toBe('future_sign_in_status');
+ });
+
+ test('preserves structured native errors', async () => {
+ const nativeError = Object.assign(new Error('Biometric authentication was canceled.'), {
+ code: 'biometric_authentication_canceled',
+ });
+ mocks.nativeModule.signInWithTrustedDevice.mockRejectedValue(nativeError);
+
+ const operation = useTrustedDevicesOnIos().signIn();
+
+ await expect(operation).rejects.toBe(nativeError);
+ await operation.catch(error => {
+ expect(isTrustedDeviceError(error)).toBe(true);
+ if (isTrustedDeviceError(error)) {
+ expect(error.code).toBe('biometric_authentication_canceled');
+ }
+ });
+ });
+
+ test('explains that the development client must contain the native methods', async () => {
+ const signInWithTrustedDevice = mocks.nativeModule.signInWithTrustedDevice;
+ Object.assign(mocks.nativeModule, { signInWithTrustedDevice: undefined });
+
+ await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow(
+ 'Biometric trusted devices require a development build containing a compatible version of @clerk/expo.',
+ );
+
+ Object.assign(mocks.nativeModule, { signInWithTrustedDevice });
+ });
+});
+
+describe('useTrustedDevices on Android', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('uses the native trusted-device bridge', async () => {
+ mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({
+ isAvailable: true,
+ unavailableReason: null,
+ });
+ mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({
+ status: 'complete',
+ createdSessionId: 'sess_android',
+ });
+
+ const trustedDevices = useTrustedDevicesOnAndroid();
+
+ await expect(trustedDevices.getAvailability({ identifierHint: 'sean@example.com' })).resolves.toEqual({
+ isAvailable: true,
+ unavailableReason: null,
+ });
+ await expect(trustedDevices.signIn({ reason: 'Confirm your identity to sign in.' })).resolves.toEqual({
+ status: 'complete',
+ createdSessionId: 'sess_android',
+ });
+ expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledWith(null, 'sean@example.com');
+ expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledWith(
+ null,
+ null,
+ 'Confirm your identity to sign in.',
+ );
+ });
+});
+
+describe('useTrustedDevices on unsupported platforms', () => {
+ test('reports unsupported availability without invoking native code', async () => {
+ const availability = await useTrustedDevicesOnUnsupportedPlatform().getAvailability();
+
+ expect(availability).toEqual({
+ isAvailable: false,
+ unavailableReason: 'unsupported_platform',
+ });
+ });
+
+ test('rejects operations that require the native implementation', async () => {
+ await expect(useTrustedDevicesOnUnsupportedPlatform().enroll()).rejects.toThrow(
+ 'Biometric trusted devices are currently only available on iOS and Android.',
+ );
+ });
+});
diff --git a/packages/expo/src/trusted-devices/errors.ts b/packages/expo/src/trusted-devices/errors.ts
new file mode 100644
index 00000000000..83be5a25aae
--- /dev/null
+++ b/packages/expo/src/trusted-devices/errors.ts
@@ -0,0 +1,28 @@
+export type TrustedDeviceErrorCode =
+ | 'unsupported_platform'
+ | 'biometric_authentication_unavailable'
+ | 'biometric_authentication_canceled'
+ | 'biometric_authentication_failed'
+ | 'key_generation_failed'
+ | 'key_not_found'
+ | 'key_invalidated'
+ | 'invalid_public_key'
+ | 'public_key_export_failed'
+ | 'unsupported_algorithm'
+ | 'signing_failed'
+ | 'key_deletion_failed'
+ | 'invalid_trusted_device_policy'
+ | 'E_TRUSTED_DEVICE_AVAILABILITY_FAILED'
+ | 'E_TRUSTED_DEVICE_LIST_FAILED'
+ | 'E_TRUSTED_DEVICE_ENROLLMENT_FAILED'
+ | 'E_TRUSTED_DEVICE_REVOCATION_FAILED'
+ | 'E_TRUSTED_DEVICE_SIGN_IN_FAILED'
+ | (string & {});
+
+export type TrustedDeviceError = Error & {
+ code: TrustedDeviceErrorCode;
+};
+
+export function isTrustedDeviceError(error: unknown): error is TrustedDeviceError {
+ return error instanceof Error && 'code' in error && typeof error.code === 'string';
+}
diff --git a/packages/expo/src/trusted-devices/index.ts b/packages/expo/src/trusted-devices/index.ts
new file mode 100644
index 00000000000..a7056efe72b
--- /dev/null
+++ b/packages/expo/src/trusted-devices/index.ts
@@ -0,0 +1,3 @@
+export * from './errors';
+export * from './types';
+export * from './useTrustedDevices';
diff --git a/packages/expo/src/trusted-devices/types.ts b/packages/expo/src/trusted-devices/types.ts
new file mode 100644
index 00000000000..6c9a71d2e8a
--- /dev/null
+++ b/packages/expo/src/trusted-devices/types.ts
@@ -0,0 +1,69 @@
+import type { SignInStatus } from '@clerk/shared/types';
+
+export type TrustedDeviceUnavailableReason =
+ | 'environment_unavailable'
+ | 'native_api_disabled'
+ | 'feature_disabled'
+ | 'unsupported_platform'
+ | 'biometric_authentication_unavailable'
+ | 'no_local_credential'
+ | 'local_key_missing'
+ | 'server_credential_missing'
+ | 'server_credential_revoked'
+ | (string & {});
+
+export type TrustedDeviceAvailability = {
+ isAvailable: boolean;
+ unavailableReason: TrustedDeviceUnavailableReason | null;
+};
+
+export type TrustedDevicePolicy = 'biometry_current_set' | 'biometry_any' | 'biometry_or_device_passcode';
+
+export type TrustedDevicePlatform = 'ios' | 'android' | (string & {});
+
+export type TrustedDeviceStatus = 'active' | 'revoked' | (string & {});
+
+export type TrustedDevice = {
+ id: string;
+ object: 'trusted_device';
+ platform: TrustedDevicePlatform;
+ appIdentifier: string;
+ name: string | null;
+ algorithm: 'ES256' | (string & {});
+ status: TrustedDeviceStatus;
+ createdAt: Date;
+ updatedAt: Date;
+ lastUsedAt: Date | null;
+ revokedAt: Date | null;
+};
+
+export type GetTrustedDeviceAvailabilityParams = {
+ id?: string;
+ identifierHint?: string;
+};
+
+export type EnrollTrustedDeviceParams = {
+ deviceName?: string;
+ identifierHint?: string;
+ reason?: string;
+ policy?: TrustedDevicePolicy;
+};
+
+export type SignInWithTrustedDeviceParams = {
+ id?: string;
+ identifierHint?: string;
+ reason?: string;
+};
+
+export type TrustedDeviceSignInResult = {
+ status: SignInStatus | (string & {});
+ createdSessionId: string | null;
+};
+
+export type UseTrustedDevicesReturn = {
+ getAvailability: (params?: GetTrustedDeviceAvailabilityParams) => Promise;
+ list: () => Promise;
+ enroll: (params?: EnrollTrustedDeviceParams) => Promise;
+ revoke: (id: string) => Promise;
+ signIn: (params?: SignInWithTrustedDeviceParams) => Promise;
+};
diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.android.ts b/packages/expo/src/trusted-devices/useTrustedDevices.android.ts
new file mode 100644
index 00000000000..124e32e9a0e
--- /dev/null
+++ b/packages/expo/src/trusted-devices/useTrustedDevices.android.ts
@@ -0,0 +1 @@
+export { useTrustedDevices } from './useTrustedDevices.shared';
diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.ios.ts b/packages/expo/src/trusted-devices/useTrustedDevices.ios.ts
new file mode 100644
index 00000000000..124e32e9a0e
--- /dev/null
+++ b/packages/expo/src/trusted-devices/useTrustedDevices.ios.ts
@@ -0,0 +1 @@
+export { useTrustedDevices } from './useTrustedDevices.shared';
diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts
new file mode 100644
index 00000000000..852ec8c73e5
--- /dev/null
+++ b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts
@@ -0,0 +1,73 @@
+import type { NativeTrustedDevice, NativeTrustedDeviceModule } from '../specs/NativeClerkModule.types';
+import { errorThrower } from '../utils/errors';
+import { ClerkExpoModule } from '../utils/native-module';
+import type { TrustedDevice, UseTrustedDevicesReturn } from './types';
+
+const DEFAULT_POLICY = 'biometry_or_device_passcode';
+
+function getNativeModule(): NativeTrustedDeviceModule {
+ const nativeModule = ClerkExpoModule;
+
+ if (
+ !nativeModule?.getTrustedDeviceAvailability ||
+ !nativeModule.listTrustedDevices ||
+ !nativeModule.enrollTrustedDevice ||
+ !nativeModule.revokeTrustedDevice ||
+ !nativeModule.signInWithTrustedDevice
+ ) {
+ return errorThrower.throw(
+ 'Biometric trusted devices require a development build containing a compatible version of @clerk/expo.',
+ );
+ }
+
+ return nativeModule as NativeTrustedDeviceModule;
+}
+
+function toTrustedDevice(device: NativeTrustedDevice): TrustedDevice {
+ return {
+ ...device,
+ createdAt: new Date(device.createdAt),
+ updatedAt: new Date(device.updatedAt),
+ lastUsedAt: device.lastUsedAt === null ? null : new Date(device.lastUsedAt),
+ revokedAt: device.revokedAt === null ? null : new Date(device.revokedAt),
+ };
+}
+
+/**
+ * Accesses biometric trusted-device enrollment and sign-in on iOS and Android.
+ *
+ * The private key and biometric prompt are managed by Clerk's native SDK.
+ */
+export function useTrustedDevices(): UseTrustedDevicesReturn {
+ return {
+ getAvailability: params =>
+ Promise.resolve().then(() =>
+ getNativeModule().getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null),
+ ),
+ list: async () => {
+ const devices = await getNativeModule().listTrustedDevices();
+ return devices.map(toTrustedDevice);
+ },
+ enroll: async params => {
+ const device = await getNativeModule().enrollTrustedDevice(
+ params?.deviceName ?? null,
+ params?.identifierHint ?? null,
+ params?.reason ?? null,
+ params?.policy ?? DEFAULT_POLICY,
+ );
+ return toTrustedDevice(device);
+ },
+ revoke: async id => {
+ const device = await getNativeModule().revokeTrustedDevice(id);
+ return toTrustedDevice(device);
+ },
+ signIn: params =>
+ Promise.resolve().then(() =>
+ getNativeModule().signInWithTrustedDevice(
+ params?.id ?? null,
+ params?.identifierHint ?? null,
+ params?.reason ?? null,
+ ),
+ ),
+ };
+}
diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.ts b/packages/expo/src/trusted-devices/useTrustedDevices.ts
new file mode 100644
index 00000000000..2eff82f9f0d
--- /dev/null
+++ b/packages/expo/src/trusted-devices/useTrustedDevices.ts
@@ -0,0 +1,30 @@
+import { errorThrower } from '../utils/errors';
+import type { UseTrustedDevicesReturn } from './types';
+
+const unsupportedAvailability = {
+ isAvailable: false,
+ unavailableReason: 'unsupported_platform',
+} as const;
+
+function unsupported(): never {
+ return errorThrower.throw('Biometric trusted devices are currently only available on iOS and Android.');
+}
+
+function rejectUnsupported(): Promise {
+ return Promise.resolve().then(unsupported);
+}
+
+/**
+ * Accesses biometric trusted-device enrollment and sign-in.
+ *
+ * Trusted devices are currently supported on iOS and Android.
+ */
+export function useTrustedDevices(): UseTrustedDevicesReturn {
+ return {
+ getAvailability: () => Promise.resolve(unsupportedAvailability),
+ list: rejectUnsupported,
+ enroll: rejectUnsupported,
+ revoke: rejectUnsupported,
+ signIn: rejectUnsupported,
+ };
+}
diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts
index 1a852882e4e..08dfacdbd12 100644
--- a/packages/expo/src/utils/native-module.ts
+++ b/packages/expo/src/utils/native-module.ts
@@ -1,10 +1,11 @@
import { Platform } from 'react-native';
import NativeClerkModule from '../specs/NativeClerkModule';
+import type { NativeAuthFlowModule, NativeTrustedDeviceModule } from '../specs/NativeClerkModule.types';
export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
-type ClerkExpoNativeModule = {
+export type ClerkExpoNativeModule = {
addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
configure(publishableKey: string, bearerToken: string | null): Promise;
getClientToken(): Promise;
@@ -14,7 +15,7 @@ type ClerkExpoNativeModule = {
didChangeClient: boolean,
didChangeDeviceToken: boolean,
): Promise;
-};
+} & Partial;
function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule {
if (!module || typeof module !== 'object') {