diff --git a/.changeset/thin-spoons-trust.md b/.changeset/thin-spoons-trust.md new file mode 100644 index 00000000000..6f1cae618aa --- /dev/null +++ b/.changeset/thin-spoons-trust.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': minor +--- + +Add iOS and Android APIs for biometric trusted-device enrollment, sign-in, availability, listing, and revocation, including structured native error codes and forward-compatible resource values. Add native authentication-flow readiness state for safely gating authenticated content and support configuring the Face ID permission message through the Expo config plugin. diff --git a/packages/expo/README.md b/packages/expo/README.md index 1fa4fbb447f..3bd26b16dd5 100644 --- a/packages/expo/README.md +++ b/packages/expo/README.md @@ -48,6 +48,47 @@ You'll learn how to create an Expo application, install `@clerk/expo`, set up yo For further information, guides, and examples visit the [Expo reference documentation](https://clerk.com/docs/references/expo/overview?utm_source=github&utm_medium=clerk_expo). +### Biometric trusted devices + +Biometric trusted-device enrollment and sign-in are supported in development builds on iOS and Android. Android requires Android 9 (API 28) or later and an enrolled Class 3 biometric. + +Trusted-device operations preserve Clerk API and native biometric error codes. Use `isTrustedDeviceError(error)` to safely inspect `error.code`; unrecognized codes and resource values remain available for forward compatibility. + +#### Face ID on iOS + +Apps that use Face ID for trusted-device enrollment or sign-in must provide `NSFaceIDUsageDescription`. You can have the Clerk config plugin add it during prebuild: + +```json +{ + "expo": { + "plugins": [ + [ + "@clerk/expo", + { + "faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID for secure sign-in." + } + ] + ] + } +} +``` + +The plugin only adds the permission when `faceIDPermission` is provided and does not overwrite `ios.infoPlist.NSFaceIDUsageDescription` if your app already defines it. + +You can also configure the key directly: + +```json +{ + "expo": { + "ios": { + "infoPlist": { + "NSFaceIDUsageDescription": "Allow $(PRODUCT_NAME) to use Face ID for secure sign-in." + } + } + } +} +``` + ## Support For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo). diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle index 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__/useAuthFlow.test.tsx b/packages/expo/src/native/__tests__/useAuthFlow.test.tsx new file mode 100644 index 00000000000..5c2bf3f916c --- /dev/null +++ b/packages/expo/src/native/__tests__/useAuthFlow.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 { useAuthFlow } from '../useAuthFlow'; + +const mocks = vi.hoisted(() => ({ + auth: { isLoaded: true, isSignedIn: true }, + getAuthFlowState: vi.fn(), + listener: undefined as ((state?: NativeAuthFlowState) => void) | undefined, + module: {} as unknown, + moduleAddListener: vi.fn(), + remove: vi.fn(), +})); + +vi.mock('../../hooks/useAuth', () => ({ + useAuth: () => mocks.auth, +})); + +vi.mock('../../utils/native-module', () => ({ + get ClerkExpoModule() { + return mocks.module; + }, +})); + +describe('useAuthFlow', () => { + beforeEach(() => { + mocks.auth = { isLoaded: true, isSignedIn: true }; + mocks.listener = undefined; + mocks.remove.mockReset(); + mocks.getAuthFlowState.mockReset(); + mocks.getAuthFlowState.mockResolvedValue({ isLoaded: true, isAuthFlowComplete: false }); + mocks.moduleAddListener.mockReset(); + mocks.moduleAddListener.mockImplementation((_eventName, listener) => { + mocks.listener = listener; + return { remove: mocks.remove }; + }); + mocks.module = { + addListener: mocks.moduleAddListener, + getAuthFlowState: mocks.getAuthFlowState, + }; + }); + + afterEach(() => { + cleanup(); + }); + + test('loads and observes native auth-flow completion state', async () => { + const { result, unmount } = renderHook(() => useAuthFlow()); + + expect(mocks.moduleAddListener).toHaveBeenCalledWith('clerkNativeAuthFlowChanged', expect.any(Function)); + + await waitFor(() => { + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: false }); + }); + + act(() => { + mocks.listener?.({ isLoaded: true, isAuthFlowComplete: true }); + }); + + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + + unmount(); + expect(mocks.remove).toHaveBeenCalledTimes(1); + }); + + test('waits for the JS session after the native auth flow completes', async () => { + mocks.auth = { isLoaded: true, isSignedIn: false }; + mocks.getAuthFlowState.mockResolvedValue({ isLoaded: true, isAuthFlowComplete: true }); + + const { result, rerender } = renderHook(() => useAuthFlow()); + + await waitFor(() => { + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: false }); + }); + + mocks.auth = { isLoaded: true, isSignedIn: true }; + rerender(); + + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + }); + + test('falls back to JS session state when native auth-flow state is unavailable', () => { + mocks.module = null; + + const { result } = renderHook(() => useAuthFlow()); + + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + expect(mocks.moduleAddListener).not.toHaveBeenCalled(); + }); + + test('falls back to JS session state when the native auth-flow state is invalid', async () => { + mocks.getAuthFlowState.mockResolvedValue({ isLoaded: true }); + + const { result } = renderHook(() => useAuthFlow()); + + await waitFor(() => { + expect(result.current).toEqual({ isLoaded: true, isAuthFlowComplete: true }); + }); + }); +}); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts index b59a8eeb106..848fd6f3112 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 { useAuthFlow } from './useAuthFlow'; +export type { UseAuthFlowReturn } from './useAuthFlow'; export { UserButton } from './UserButton'; export { UserProfileView } from './UserProfileView'; export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/native/useAuthFlow.ts b/packages/expo/src/native/useAuthFlow.ts new file mode 100644 index 00000000000..d1821c2202a --- /dev/null +++ b/packages/expo/src/native/useAuthFlow.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 UseAuthFlowReturn = NativeAuthFlowState; + +type NativeAuthFlowEventEmitter = { + addListener( + eventName: typeof nativeAuthFlowChangedEvent, + listener: (state?: NativeAuthFlowState) => void, + ): { remove: () => void }; + getAuthFlowState(): Promise; +}; + +const initialNativeState: NativeAuthFlowState = { + isLoaded: false, + isAuthFlowComplete: false, +}; + +function getNativeAuthFlowModule(): NativeAuthFlowEventEmitter | null { + if (ClerkExpo && typeof ClerkExpo.addListener === 'function' && typeof ClerkExpo.getAuthFlowState === 'function') { + return ClerkExpo as NativeAuthFlowEventEmitter; + } + + return null; +} + +function isNativeAuthFlowState(state: NativeAuthFlowState | undefined): state is NativeAuthFlowState { + return typeof state?.isLoaded === 'boolean' && typeof state.isAuthFlowComplete === 'boolean'; +} + +/** + * Reports when authentication and 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 useAuthFlow(): UseAuthFlowReturn { + const { isLoaded: isJsLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const [nativeState, setNativeState] = useState(initialNativeState); + const [useJsFallback, setUseJsFallback] = useState(false); + const nativeModule = getNativeAuthFlowModule(); + + useEffect(() => { + if (!nativeModule) { + setUseJsFallback(true); + return; + } + + let isMounted = true; + let didReceiveEvent = false; + let subscription: { remove: () => void } | undefined; + + setUseJsFallback(false); + + try { + subscription = nativeModule.addListener(nativeAuthFlowChangedEvent, state => { + if (!isNativeAuthFlowState(state)) { + return; + } + + didReceiveEvent = true; + setNativeState(state); + }); + + void nativeModule + .getAuthFlowState() + .then(state => { + if (!isMounted || didReceiveEvent) { + return; + } + + if (isNativeAuthFlowState(state)) { + setNativeState(state); + } else { + setUseJsFallback(true); + } + }) + .catch(error => { + if (!isMounted) { + return; + } + + setUseJsFallback(true); + if (__DEV__) { + console.error('[useAuthFlow] Failed to get native auth-flow state:', error); + } + }); + } catch (error) { + setUseJsFallback(true); + if (__DEV__) { + console.error('[useAuthFlow] Failed to observe native auth-flow state:', error); + } + } + + return () => { + isMounted = false; + subscription?.remove(); + }; + }, [nativeModule]); + + if (!nativeModule || useJsFallback) { + return { + isLoaded: isJsLoaded, + isAuthFlowComplete: Boolean(isJsLoaded && isSignedIn), + }; + } + + return { + isLoaded: Boolean(isJsLoaded && nativeState.isLoaded), + isAuthFlowComplete: Boolean(isJsLoaded && isSignedIn && nativeState.isAuthFlowComplete), + }; +} diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index cced94ad12a..d43321ad832 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -1,6 +1,8 @@ import { requireOptionalNativeModule } from 'expo'; -interface Spec { +import type { NativeAuthFlowModule, NativeTrustedDeviceModule } from './NativeClerkModule.types'; + +interface Spec extends NativeAuthFlowModule, NativeTrustedDeviceModule { // Exposed by Expo Modules EventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index c8eb967e84d..0388eb0d121 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -1,6 +1,8 @@ import { requireOptionalNativeModule } from 'expo'; -export interface Spec { +import type { NativeAuthFlowModule, NativeTrustedDeviceModule } from './NativeClerkModule.types'; + +export interface Spec extends NativeAuthFlowModule, NativeTrustedDeviceModule { // Exposed by Expo Modules EventEmitter for internal native client change events. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts new file mode 100644 index 00000000000..6929dd3cec6 --- /dev/null +++ b/packages/expo/src/specs/NativeClerkModule.types.ts @@ -0,0 +1,45 @@ +import type { + TrustedDeviceAvailability, + TrustedDevicePolicy, + TrustedDeviceSignInResult, +} from '../trusted-devices/types'; + +export type NativeAuthFlowState = { + isLoaded: boolean; + isAuthFlowComplete: boolean; +}; + +export type NativeAuthFlowModule = { + getAuthFlowState(): Promise; +}; + +export type NativeTrustedDevice = { + id: string; + object: 'trusted_device'; + platform: 'ios' | 'android' | (string & {}); + appIdentifier: string; + name: string | null; + algorithm: 'ES256' | (string & {}); + status: 'active' | 'revoked' | (string & {}); + createdAt: number; + updatedAt: number; + lastUsedAt: number | null; + revokedAt: number | null; +}; + +export type NativeTrustedDeviceModule = { + getTrustedDeviceAvailability(id: string | null, identifierHint: string | null): Promise; + listTrustedDevices(): Promise; + enrollTrustedDevice( + deviceName: string | null, + identifierHint: string | null, + reason: string | null, + policy: TrustedDevicePolicy, + ): Promise; + revokeTrustedDevice(id: string): Promise; + signInWithTrustedDevice( + id: string | null, + identifierHint: string | null, + reason: string | null, + ): Promise; +}; diff --git a/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts new file mode 100644 index 00000000000..61c85186653 --- /dev/null +++ b/packages/expo/src/trusted-devices/__tests__/useTrustedDevices.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { isTrustedDeviceError } from '../errors'; +import { useTrustedDevices as useTrustedDevicesOnUnsupportedPlatform } from '../useTrustedDevices'; +import { useTrustedDevices as useTrustedDevicesOnAndroid } from '../useTrustedDevices.android'; +import { useTrustedDevices as useTrustedDevicesOnIos } from '../useTrustedDevices.ios'; + +const mocks = vi.hoisted(() => ({ + nativeModule: { + getTrustedDeviceAvailability: vi.fn(), + listTrustedDevices: vi.fn(), + enrollTrustedDevice: vi.fn(), + revokeTrustedDevice: vi.fn(), + signInWithTrustedDevice: vi.fn(), + }, +})); + +vi.mock('../../utils/native-module', () => ({ + ClerkExpoModule: mocks.nativeModule, +})); + +vi.mock('react-native', () => ({ + Platform: { + OS: 'ios', + }, +})); + +const nativeTrustedDevice = { + id: 'td_123', + object: 'trusted_device' as const, + platform: 'ios' as const, + appIdentifier: 'com.example.app', + name: "Sean's iPhone", + algorithm: 'ES256' as const, + status: 'active' as const, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_100_000, + lastUsedAt: 1_700_000_200_000, + revokedAt: null, +}; + +describe('useTrustedDevices on iOS', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('checks availability for an optional credential selector', async () => { + mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({ + isAvailable: true, + unavailableReason: null, + }); + + const trustedDevices = useTrustedDevicesOnIos(); + const availability = await trustedDevices.getAvailability({ + id: 'td_123', + identifierHint: 'sean@example.com', + }); + + expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledWith('td_123', 'sean@example.com'); + expect(availability).toEqual({ isAvailable: true, unavailableReason: null }); + }); + + test('lists trusted devices and converts native timestamps to dates', async () => { + mocks.nativeModule.listTrustedDevices.mockResolvedValue([nativeTrustedDevice]); + + const [trustedDevice] = await useTrustedDevicesOnIos().list(); + + expect(trustedDevice).toEqual({ + ...nativeTrustedDevice, + createdAt: new Date(nativeTrustedDevice.createdAt), + updatedAt: new Date(nativeTrustedDevice.updatedAt), + lastUsedAt: new Date(nativeTrustedDevice.lastUsedAt), + revokedAt: null, + }); + }); + + test('enrolls with the safe default authentication policy', async () => { + mocks.nativeModule.enrollTrustedDevice.mockResolvedValue(nativeTrustedDevice); + + const trustedDevice = await useTrustedDevicesOnIos().enroll({ + deviceName: "Sean's iPhone", + identifierHint: 'sean@example.com', + reason: 'Use Face ID to trust this device.', + }); + + expect(mocks.nativeModule.enrollTrustedDevice).toHaveBeenCalledWith( + "Sean's iPhone", + 'sean@example.com', + 'Use Face ID to trust this device.', + 'biometry_or_device_passcode', + ); + expect(trustedDevice.createdAt).toEqual(new Date(nativeTrustedDevice.createdAt)); + }); + + test('revokes a trusted device by ID', async () => { + mocks.nativeModule.revokeTrustedDevice.mockResolvedValue({ + ...nativeTrustedDevice, + status: 'revoked', + revokedAt: 1_700_000_300_000, + }); + + const trustedDevice = await useTrustedDevicesOnIos().revoke('td_123'); + + expect(mocks.nativeModule.revokeTrustedDevice).toHaveBeenCalledWith('td_123'); + expect(trustedDevice.status).toBe('revoked'); + expect(trustedDevice.revokedAt).toEqual(new Date(1_700_000_300_000)); + }); + + test('signs in through the native one-shot trusted-device flow', async () => { + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + status: 'complete', + createdSessionId: 'sess_123', + }); + + const result = await useTrustedDevicesOnIos().signIn({ + identifierHint: 'sean@example.com', + reason: 'Use Face ID to sign in.', + }); + + expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledWith( + null, + 'sean@example.com', + 'Use Face ID to sign in.', + ); + expect(result).toEqual({ status: 'complete', createdSessionId: 'sess_123' }); + }); + + test('preserves forward-compatible native values', async () => { + mocks.nativeModule.listTrustedDevices.mockResolvedValue([ + { + ...nativeTrustedDevice, + platform: 'visionos', + algorithm: 'ES384', + status: 'pending_review', + }, + ]); + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + status: 'future_sign_in_status', + createdSessionId: null, + }); + + const trustedDevices = useTrustedDevicesOnIos(); + const [device] = await trustedDevices.list(); + const signIn = await trustedDevices.signIn(); + + expect(device).toMatchObject({ + platform: 'visionos', + algorithm: 'ES384', + status: 'pending_review', + }); + expect(signIn.status).toBe('future_sign_in_status'); + }); + + test('preserves structured native errors', async () => { + const nativeError = Object.assign(new Error('Biometric authentication was canceled.'), { + code: 'biometric_authentication_canceled', + }); + mocks.nativeModule.signInWithTrustedDevice.mockRejectedValue(nativeError); + + const operation = useTrustedDevicesOnIos().signIn(); + + await expect(operation).rejects.toBe(nativeError); + await operation.catch(error => { + expect(isTrustedDeviceError(error)).toBe(true); + if (isTrustedDeviceError(error)) { + expect(error.code).toBe('biometric_authentication_canceled'); + } + }); + }); + + test('explains that the development client must contain the native methods', async () => { + const signInWithTrustedDevice = mocks.nativeModule.signInWithTrustedDevice; + Object.assign(mocks.nativeModule, { signInWithTrustedDevice: undefined }); + + await expect(useTrustedDevicesOnIos().signIn()).rejects.toThrow( + 'Biometric trusted devices require a development build containing a compatible version of @clerk/expo.', + ); + + Object.assign(mocks.nativeModule, { signInWithTrustedDevice }); + }); +}); + +describe('useTrustedDevices on Android', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('uses the native trusted-device bridge', async () => { + mocks.nativeModule.getTrustedDeviceAvailability.mockResolvedValue({ + isAvailable: true, + unavailableReason: null, + }); + mocks.nativeModule.signInWithTrustedDevice.mockResolvedValue({ + status: 'complete', + createdSessionId: 'sess_android', + }); + + const trustedDevices = useTrustedDevicesOnAndroid(); + + await expect(trustedDevices.getAvailability({ identifierHint: 'sean@example.com' })).resolves.toEqual({ + isAvailable: true, + unavailableReason: null, + }); + await expect(trustedDevices.signIn({ reason: 'Confirm your identity to sign in.' })).resolves.toEqual({ + status: 'complete', + createdSessionId: 'sess_android', + }); + expect(mocks.nativeModule.getTrustedDeviceAvailability).toHaveBeenCalledWith(null, 'sean@example.com'); + expect(mocks.nativeModule.signInWithTrustedDevice).toHaveBeenCalledWith( + null, + null, + 'Confirm your identity to sign in.', + ); + }); +}); + +describe('useTrustedDevices on unsupported platforms', () => { + test('reports unsupported availability without invoking native code', async () => { + const availability = await useTrustedDevicesOnUnsupportedPlatform().getAvailability(); + + expect(availability).toEqual({ + isAvailable: false, + unavailableReason: 'unsupported_platform', + }); + }); + + test('rejects operations that require the native implementation', async () => { + await expect(useTrustedDevicesOnUnsupportedPlatform().enroll()).rejects.toThrow( + 'Biometric trusted devices are currently only available on iOS and Android.', + ); + }); +}); diff --git a/packages/expo/src/trusted-devices/errors.ts b/packages/expo/src/trusted-devices/errors.ts new file mode 100644 index 00000000000..83be5a25aae --- /dev/null +++ b/packages/expo/src/trusted-devices/errors.ts @@ -0,0 +1,28 @@ +export type TrustedDeviceErrorCode = + | 'unsupported_platform' + | 'biometric_authentication_unavailable' + | 'biometric_authentication_canceled' + | 'biometric_authentication_failed' + | 'key_generation_failed' + | 'key_not_found' + | 'key_invalidated' + | 'invalid_public_key' + | 'public_key_export_failed' + | 'unsupported_algorithm' + | 'signing_failed' + | 'key_deletion_failed' + | 'invalid_trusted_device_policy' + | 'E_TRUSTED_DEVICE_AVAILABILITY_FAILED' + | 'E_TRUSTED_DEVICE_LIST_FAILED' + | 'E_TRUSTED_DEVICE_ENROLLMENT_FAILED' + | 'E_TRUSTED_DEVICE_REVOCATION_FAILED' + | 'E_TRUSTED_DEVICE_SIGN_IN_FAILED' + | (string & {}); + +export type TrustedDeviceError = Error & { + code: TrustedDeviceErrorCode; +}; + +export function isTrustedDeviceError(error: unknown): error is TrustedDeviceError { + return error instanceof Error && 'code' in error && typeof error.code === 'string'; +} diff --git a/packages/expo/src/trusted-devices/index.ts b/packages/expo/src/trusted-devices/index.ts new file mode 100644 index 00000000000..a7056efe72b --- /dev/null +++ b/packages/expo/src/trusted-devices/index.ts @@ -0,0 +1,3 @@ +export * from './errors'; +export * from './types'; +export * from './useTrustedDevices'; diff --git a/packages/expo/src/trusted-devices/types.ts b/packages/expo/src/trusted-devices/types.ts new file mode 100644 index 00000000000..6c9a71d2e8a --- /dev/null +++ b/packages/expo/src/trusted-devices/types.ts @@ -0,0 +1,69 @@ +import type { SignInStatus } from '@clerk/shared/types'; + +export type TrustedDeviceUnavailableReason = + | 'environment_unavailable' + | 'native_api_disabled' + | 'feature_disabled' + | 'unsupported_platform' + | 'biometric_authentication_unavailable' + | 'no_local_credential' + | 'local_key_missing' + | 'server_credential_missing' + | 'server_credential_revoked' + | (string & {}); + +export type TrustedDeviceAvailability = { + isAvailable: boolean; + unavailableReason: TrustedDeviceUnavailableReason | null; +}; + +export type TrustedDevicePolicy = 'biometry_current_set' | 'biometry_any' | 'biometry_or_device_passcode'; + +export type TrustedDevicePlatform = 'ios' | 'android' | (string & {}); + +export type TrustedDeviceStatus = 'active' | 'revoked' | (string & {}); + +export type TrustedDevice = { + id: string; + object: 'trusted_device'; + platform: TrustedDevicePlatform; + appIdentifier: string; + name: string | null; + algorithm: 'ES256' | (string & {}); + status: TrustedDeviceStatus; + createdAt: Date; + updatedAt: Date; + lastUsedAt: Date | null; + revokedAt: Date | null; +}; + +export type GetTrustedDeviceAvailabilityParams = { + id?: string; + identifierHint?: string; +}; + +export type EnrollTrustedDeviceParams = { + deviceName?: string; + identifierHint?: string; + reason?: string; + policy?: TrustedDevicePolicy; +}; + +export type SignInWithTrustedDeviceParams = { + id?: string; + identifierHint?: string; + reason?: string; +}; + +export type TrustedDeviceSignInResult = { + status: SignInStatus | (string & {}); + createdSessionId: string | null; +}; + +export type UseTrustedDevicesReturn = { + getAvailability: (params?: GetTrustedDeviceAvailabilityParams) => Promise; + list: () => Promise; + enroll: (params?: EnrollTrustedDeviceParams) => Promise; + revoke: (id: string) => Promise; + signIn: (params?: SignInWithTrustedDeviceParams) => Promise; +}; diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.android.ts b/packages/expo/src/trusted-devices/useTrustedDevices.android.ts new file mode 100644 index 00000000000..124e32e9a0e --- /dev/null +++ b/packages/expo/src/trusted-devices/useTrustedDevices.android.ts @@ -0,0 +1 @@ +export { useTrustedDevices } from './useTrustedDevices.shared'; diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.ios.ts b/packages/expo/src/trusted-devices/useTrustedDevices.ios.ts new file mode 100644 index 00000000000..124e32e9a0e --- /dev/null +++ b/packages/expo/src/trusted-devices/useTrustedDevices.ios.ts @@ -0,0 +1 @@ +export { useTrustedDevices } from './useTrustedDevices.shared'; diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts new file mode 100644 index 00000000000..852ec8c73e5 --- /dev/null +++ b/packages/expo/src/trusted-devices/useTrustedDevices.shared.ts @@ -0,0 +1,73 @@ +import type { NativeTrustedDevice, NativeTrustedDeviceModule } from '../specs/NativeClerkModule.types'; +import { errorThrower } from '../utils/errors'; +import { ClerkExpoModule } from '../utils/native-module'; +import type { TrustedDevice, UseTrustedDevicesReturn } from './types'; + +const DEFAULT_POLICY = 'biometry_or_device_passcode'; + +function getNativeModule(): NativeTrustedDeviceModule { + const nativeModule = ClerkExpoModule; + + if ( + !nativeModule?.getTrustedDeviceAvailability || + !nativeModule.listTrustedDevices || + !nativeModule.enrollTrustedDevice || + !nativeModule.revokeTrustedDevice || + !nativeModule.signInWithTrustedDevice + ) { + return errorThrower.throw( + 'Biometric trusted devices require a development build containing a compatible version of @clerk/expo.', + ); + } + + return nativeModule as NativeTrustedDeviceModule; +} + +function toTrustedDevice(device: NativeTrustedDevice): TrustedDevice { + return { + ...device, + createdAt: new Date(device.createdAt), + updatedAt: new Date(device.updatedAt), + lastUsedAt: device.lastUsedAt === null ? null : new Date(device.lastUsedAt), + revokedAt: device.revokedAt === null ? null : new Date(device.revokedAt), + }; +} + +/** + * Accesses biometric trusted-device enrollment and sign-in on iOS and Android. + * + * The private key and biometric prompt are managed by Clerk's native SDK. + */ +export function useTrustedDevices(): UseTrustedDevicesReturn { + return { + getAvailability: params => + Promise.resolve().then(() => + getNativeModule().getTrustedDeviceAvailability(params?.id ?? null, params?.identifierHint ?? null), + ), + list: async () => { + const devices = await getNativeModule().listTrustedDevices(); + return devices.map(toTrustedDevice); + }, + enroll: async params => { + const device = await getNativeModule().enrollTrustedDevice( + params?.deviceName ?? null, + params?.identifierHint ?? null, + params?.reason ?? null, + params?.policy ?? DEFAULT_POLICY, + ); + return toTrustedDevice(device); + }, + revoke: async id => { + const device = await getNativeModule().revokeTrustedDevice(id); + return toTrustedDevice(device); + }, + signIn: params => + Promise.resolve().then(() => + getNativeModule().signInWithTrustedDevice( + params?.id ?? null, + params?.identifierHint ?? null, + params?.reason ?? null, + ), + ), + }; +} diff --git a/packages/expo/src/trusted-devices/useTrustedDevices.ts b/packages/expo/src/trusted-devices/useTrustedDevices.ts new file mode 100644 index 00000000000..2eff82f9f0d --- /dev/null +++ b/packages/expo/src/trusted-devices/useTrustedDevices.ts @@ -0,0 +1,30 @@ +import { errorThrower } from '../utils/errors'; +import type { UseTrustedDevicesReturn } from './types'; + +const unsupportedAvailability = { + isAvailable: false, + unavailableReason: 'unsupported_platform', +} as const; + +function unsupported(): never { + return errorThrower.throw('Biometric trusted devices are currently only available on iOS and Android.'); +} + +function rejectUnsupported(): Promise { + return Promise.resolve().then(unsupported); +} + +/** + * Accesses biometric trusted-device enrollment and sign-in. + * + * Trusted devices are currently supported on iOS and Android. + */ +export function useTrustedDevices(): UseTrustedDevicesReturn { + return { + getAvailability: () => Promise.resolve(unsupportedAvailability), + list: rejectUnsupported, + enroll: rejectUnsupported, + revoke: rejectUnsupported, + signIn: rejectUnsupported, + }; +} diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index 1a852882e4e..08dfacdbd12 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -1,10 +1,11 @@ import { Platform } from 'react-native'; import NativeClerkModule from '../specs/NativeClerkModule'; +import type { NativeAuthFlowModule, NativeTrustedDeviceModule } from '../specs/NativeClerkModule.types'; export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; -type ClerkExpoNativeModule = { +export type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; getClientToken(): Promise; @@ -14,7 +15,7 @@ type ClerkExpoNativeModule = { didChangeClient: boolean, didChangeDeviceToken: boolean, ): Promise; -}; +} & Partial; function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { if (!module || typeof module !== 'object') {