diff --git a/.changeset/expo-native-single-token-sync-bridge.md b/.changeset/expo-native-single-token-sync-bridge.md new file mode 100644 index 00000000000..0163480dc37 --- /dev/null +++ b/.changeset/expo-native-single-token-sync-bridge.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo-native-components': minor +--- + +Add native module support for the upcoming single-token client sync between `@clerk/expo` and the Clerk iOS and Android SDKs. The native SDK's stored device token becomes the one token both runtimes use, and each side is notified to reload its own client when the other changes it. Existing sync behavior is unchanged until `@clerk/expo` adopts it. diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt new file mode 100644 index 00000000000..8903f4424c8 --- /dev/null +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkClientInvalidation.kt @@ -0,0 +1,87 @@ +package expo.modules.clerk + +import com.clerk.api.network.model.client.Client + +/** The native client state that JS must refetch its own client for when it changes. */ +internal data class ClerkClientFingerprint( + val clientId: String?, + val lastActiveSessionId: String?, + val sessions: List, + val activeUserId: String?, + val activeUserUpdatedAt: Long?, + val deviceToken: String? +) { + data class SessionState(val id: String, val status: String) + + companion object { + fun from(client: Client?, deviceToken: String?): ClerkClientFingerprint { + val activeUser = client?.sessions?.firstOrNull { it.id == client.lastActiveSessionId }?.user + return ClerkClientFingerprint( + clientId = client?.id, + lastActiveSessionId = client?.lastActiveSessionId, + sessions = client?.sessions?.map { SessionState(it.id, it.status.name) }.orEmpty(), + activeUserId = activeUser?.id, + activeUserUpdatedAt = activeUser?.updatedAt, + deviceToken = deviceToken + ) + } + } +} + +/** + * Emits one payload-free invalidation per main-loop turn in which the fingerprint moved away from + * the last state JS was told about. Not thread-safe: call it from the main thread only. + */ +internal class ClerkClientInvalidationTracker( + private val schedule: (() -> Unit) -> Unit, + private val emit: () -> Unit +) { + private var baseline: ClerkClientFingerprint? = null + private var latest: ClerkClientFingerprint? = null + private var isFlushScheduled = false + + /** Sets the state JS already knows about without emitting. */ + fun reset(fingerprint: ClerkClientFingerprint?) { + baseline = fingerprint + latest = fingerprint + } + + fun observe(fingerprint: ClerkClientFingerprint) { + if (baseline == null) return + latest = fingerprint + if (fingerprint == baseline || isFlushScheduled) return + isFlushScheduled = true + schedule(::flush) + } + + /** A token JS wrote itself is not news to JS, so it must not echo back as an invalidation. */ + fun acknowledgeDeviceToken(token: String?, current: ClerkClientFingerprint) { + baseline = baseline?.copy(deviceToken = token) + observe(current) + } + + private fun flush() { + isFlushScheduled = false + val current = latest ?: return + if (current == baseline) return + baseline = current + emit() + } +} + +internal data class ClerkDeviceTokenBridgeError(val code: String, val message: String) + +internal fun clerkSetDeviceTokenBridgeError(throwable: Throwable): ClerkDeviceTokenBridgeError = when (throwable) { + is IllegalArgumentException -> ClerkDeviceTokenBridgeError( + "E_INVALID_DEVICE_TOKEN", + throwable.message ?: "Device token must not be blank" + ) + is IllegalStateException -> ClerkDeviceTokenBridgeError( + "E_NOT_CONFIGURED", + throwable.message ?: "Clerk must be configured with configureNative before syncing client state." + ) + else -> ClerkDeviceTokenBridgeError( + "E_SET_DEVICE_TOKEN_FAILED", + throwable.message ?: "Unable to set the device token" + ) +} diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index c329174372d..84ec5128587 100644 --- a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -3,6 +3,8 @@ package expo.modules.clerk import android.content.Context +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -37,12 +39,14 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext 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 NATIVE_CLIENT_INVALIDATED_EVENT = "clerkNativeClientInvalidated" private const val HOST_SDK_HEADER = "x-clerk-host-sdk" private const val HOST_SDK_VERSION_HEADER = "x-clerk-host-sdk-version" private const val HOST_SDK = "expo" @@ -204,6 +208,12 @@ class ClerkExpoModule : Module() { private var lastObservedClientState: ClientStateSnapshot? = null private var jsOriginatedClientSyncDepth = 0 private var configuredPublishableKey: String? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var clientInvalidationObserverJob: Job? = null + private val clientInvalidationTracker = ClerkClientInvalidationTracker( + schedule = { flush -> mainHandler.post(flush) }, + emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT, emptyMap()) } + ) private data class AuthFlowStateSnapshot( val isLoaded: Boolean, @@ -238,7 +248,7 @@ class ClerkExpoModule : Module() { override fun definition() = ModuleDefinition { Name("ClerkExpo") - Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT) + Events(NATIVE_AUTH_FLOW_CHANGED_EVENT, NATIVE_CLIENT_CHANGED_EVENT, NATIVE_CLIENT_INVALIDATED_EVENT) OnCreate { sharedInstance = this@ClerkExpoModule @@ -253,6 +263,9 @@ class ClerkExpoModule : Module() { authFlowStateObserverJob = null clientStateObserverJob?.cancel() clientStateObserverJob = null + clientInvalidationObserverJob?.cancel() + clientInvalidationObserverJob = null + mainHandler.removeCallbacksAndMessages(null) } AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise -> @@ -263,6 +276,22 @@ class ClerkExpoModule : Module() { getClientToken(promise) } + AsyncFunction("configureNative") { pubKey: String, seedDeviceToken: String?, promise: Promise -> + configureNative(pubKey, seedDeviceToken, promise) + } + + AsyncFunction("getDeviceToken") { promise: Promise -> + getDeviceToken(promise) + } + + AsyncFunction("setDeviceToken") { token: String?, expected: String?, promise: Promise -> + setDeviceToken(token, expected, promise) + } + + AsyncFunction("refreshClient") { promise: Promise -> + refreshClient(promise) + } + AsyncFunction("getAuthFlowState") { promise: Promise -> promise.resolve(authFlowStatePayload()) } @@ -615,6 +644,140 @@ class ClerkExpoModule : Module() { } } + // MARK: - single-token client sync + + private fun configureNative(pubKey: String, seedDeviceToken: String?, promise: Promise) { + val context = reactContext ?: run { + promise.reject("E_CONFIGURE_FAILED", "React context is not available", null) + return + } + + coroutineScope.launch { + try { + val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey + val didConfigure = when { + activePublishableKey == null -> { + Clerk.initialize(context, pubKey, clerkConfigurationOptions()) + true + } + activePublishableKey != pubKey -> { + Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions()) + true + } + else -> false + } + if (didConfigure) { + configuredPublishableKey = pubKey + appContext.currentActivity?.let { Clerk.attachActivity(it) } + // Must follow initialize(), which resets customTheme. + loadThemeFromAssets(context) + } + startClientStateObserver() + + val didAdoptSeed = adoptSeedDeviceTokenIfNeeded(seedDeviceToken) + startClientInvalidationObserver() + lastObservedClientState = clientStateSnapshot() + if (didAdoptSeed) { + // An initialization refresh started before the seed was stored is fenced off by the token change. + launch { + val result = Clerk.refreshClient() + if (result is ClerkResult.Failure) { + debugLog(TAG, "configureNative - refresh after seed adoption failed: ${result.error}") + } + clientInvalidationTracker.observe(clientFingerprint()) + } + } + promise.resolve(null) + } catch (e: Exception) { + promise.reject("E_CONFIGURE_FAILED", "Failed to configure Clerk SDK: ${e.message}", e) + } + } + } + + private suspend fun adoptSeedDeviceTokenIfNeeded(seedDeviceToken: String?): Boolean { + val seed = seedDeviceToken?.trim()?.takeIf { it.isNotEmpty() } ?: return false + return withContext(Dispatchers.IO) { + Clerk.getDeviceToken() == null && Clerk.setDeviceToken(seed, null) + } + } + + private fun startClientInvalidationObserver() { + clientInvalidationTracker.reset(clientFingerprint()) + if (clientInvalidationObserverJob != null) { + return + } + + clientInvalidationObserverJob = coroutineScope.launch { + Clerk.clientFlow.collect { client -> + clientInvalidationTracker.observe(clientFingerprint(client)) + } + } + } + + private fun clientFingerprint(client: Client? = Clerk.clientFlow.value): ClerkClientFingerprint { + return ClerkClientFingerprint.from(client, clientStateSnapshot(client).deviceToken) + } + + private fun isClerkConfigured(): Boolean = Clerk.publishableKey != null + + private fun getDeviceToken(promise: Promise) { + if (!isClerkConfigured()) { + promise.resolve(null) + return + } + try { + promise.resolve(Clerk.getDeviceToken()) + } catch (e: Exception) { + promise.reject("E_GET_DEVICE_TOKEN_FAILED", e.message ?: "Unable to read the device token", e) + } + } + + private fun setDeviceToken(token: String?, expected: String?, promise: Promise) { + coroutineScope.launch { + try { + val didSet = withContext(Dispatchers.IO) { Clerk.setDeviceToken(token, expected) } + val fingerprint = clientFingerprint() + if (didSet) { + clientInvalidationTracker.acknowledgeDeviceToken(fingerprint.deviceToken, fingerprint) + } else { + clientInvalidationTracker.observe(fingerprint) + } + promise.resolve(didSet) + } catch (e: Exception) { + val error = clerkSetDeviceTokenBridgeError(e) + promise.reject(error.code, error.message, e) + } + } + } + + private fun refreshClient(promise: Promise) { + if (!isClerkConfigured()) { + promise.reject( + "E_NOT_CONFIGURED", + "Clerk must be configured with configureNative before syncing client state.", + null + ) + return + } + + coroutineScope.launch { + try { + val result = Clerk.refreshClient() + clientInvalidationTracker.observe(clientFingerprint()) + when (result) { + is ClerkResult.Success -> promise.resolve(null) + is ClerkResult.Failure -> promise.reject( + "E_REFRESH_CLIENT_FAILED", + result.error?.firstMessage() ?: result.throwable?.message ?: "Client refresh failed", + result.throwable + ) + } + } catch (e: Exception) { + promise.reject("E_REFRESH_CLIENT_FAILED", e.message ?: "Client refresh failed", e) + } + } + } + // MARK: - getClientToken private fun getClientToken(promise: Promise) { diff --git a/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt new file mode 100644 index 00000000000..76f75c810b1 --- /dev/null +++ b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkClientInvalidationTest.kt @@ -0,0 +1,127 @@ +package expo.modules.clerk + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ClerkClientInvalidationTest { + private val scheduled = mutableListOf<() -> Unit>() + private var emitCount = 0 + + private fun tracker() = ClerkClientInvalidationTracker( + schedule = { scheduled.add(it) }, + emit = { emitCount += 1 } + ) + + private fun runLoopTurn() { + val work = scheduled.toList() + scheduled.clear() + work.forEach { it() } + } + + private fun fingerprint( + clientId: String? = "client_1", + sessions: List = listOf(ClerkClientFingerprint.SessionState("sess_1", "ACTIVE")), + userUpdatedAt: Long? = 1, + deviceToken: String? = "token_1" + ) = ClerkClientFingerprint( + clientId = clientId, + lastActiveSessionId = sessions.firstOrNull()?.id, + sessions = sessions, + activeUserId = if (sessions.isEmpty()) null else "user_1", + activeUserUpdatedAt = if (sessions.isEmpty()) null else userUpdatedAt, + deviceToken = deviceToken + ) + + @Before + fun setUp() { + scheduled.clear() + emitCount = 0 + } + + @Test + fun `does not emit before a baseline or for unchanged state`() { + val tracker = tracker() + tracker.observe(fingerprint()) + assertTrue(scheduled.isEmpty()) + + tracker.reset(fingerprint()) + tracker.observe(fingerprint()) + runLoopTurn() + assertEquals(0, emitCount) + } + + @Test + fun `emits for each fingerprint field`() { + val changes = listOf( + fingerprint(clientId = "client_2"), + fingerprint(sessions = listOf(ClerkClientFingerprint.SessionState("sess_1", "ENDED"))), + fingerprint(sessions = emptyList()), + fingerprint(userUpdatedAt = 2), + fingerprint(deviceToken = "token_2") + ) + + for (change in changes) { + val tracker = tracker() + tracker.reset(fingerprint()) + emitCount = 0 + tracker.observe(change) + runLoopTurn() + assertEquals(change.toString(), 1, emitCount) + } + } + + @Test + fun `coalesces changes within one loop turn`() { + val tracker = tracker() + tracker.reset(fingerprint()) + + tracker.observe(fingerprint(deviceToken = "token_2")) + tracker.observe(fingerprint(clientId = "client_2", deviceToken = "token_2")) + tracker.observe(fingerprint(clientId = "client_3", deviceToken = "token_3")) + assertEquals(1, scheduled.size) + runLoopTurn() + assertEquals(1, emitCount) + + tracker.observe(fingerprint(clientId = "client_3", deviceToken = "token_3")) + runLoopTurn() + assertEquals(1, emitCount) + + tracker.observe(fingerprint(clientId = "client_4", deviceToken = "token_3")) + runLoopTurn() + assertEquals(2, emitCount) + } + + @Test + fun `a change reverted within one loop turn does not emit`() { + val tracker = tracker() + tracker.reset(fingerprint()) + + tracker.observe(fingerprint(clientId = "client_2")) + tracker.observe(fingerprint()) + runLoopTurn() + assertEquals(0, emitCount) + } + + @Test + fun `an acknowledged device token does not echo`() { + val tracker = tracker() + tracker.reset(fingerprint()) + + tracker.acknowledgeDeviceToken("token_2", fingerprint(deviceToken = "token_2")) + runLoopTurn() + assertEquals(0, emitCount) + + tracker.acknowledgeDeviceToken("token_3", fingerprint(clientId = "client_2", deviceToken = "token_3")) + runLoopTurn() + assertEquals(1, emitCount) + } + + @Test + fun `maps setDeviceToken exceptions to stable codes`() { + assertEquals("E_INVALID_DEVICE_TOKEN", clerkSetDeviceTokenBridgeError(IllegalArgumentException("blank")).code) + assertEquals("E_NOT_CONFIGURED", clerkSetDeviceTokenBridgeError(IllegalStateException("not initialized")).code) + assertEquals("E_SET_DEVICE_TOKEN_FAILED", clerkSetDeviceTokenBridgeError(RuntimeException("boom")).code) + } +} diff --git a/packages/expo-native-components/ios/ClerkClientInvalidation.swift b/packages/expo-native-components/ios/ClerkClientInvalidation.swift new file mode 100644 index 00000000000..478d6da60a1 --- /dev/null +++ b/packages/expo-native-components/ios/ClerkClientInvalidation.swift @@ -0,0 +1,105 @@ +import Foundation +@_spi(FrameworkIntegration) import ClerkKit + +/// The native client state that JS must refetch its own client for when it changes. +struct ClerkClientFingerprint: Equatable { + struct SessionState: Equatable { + let id: String + let status: String + } + + let clientId: String? + let lastActiveSessionId: String? + let sessions: [SessionState] + let activeUserId: String? + let activeUserUpdatedAt: Double? + var deviceToken: String? +} + +extension ClerkClientFingerprint { + init(client: Client?, deviceToken: String?) { + let activeUser = client?.sessions.first { $0.id == client?.lastActiveSessionId }?.user + self.init( + clientId: client?.id, + lastActiveSessionId: client?.lastActiveSessionId, + sessions: client?.sessions.map { SessionState(id: $0.id, status: $0.status.rawValue) } ?? [], + activeUserId: activeUser?.id, + activeUserUpdatedAt: activeUser?.updatedAt.timeIntervalSince1970, + deviceToken: deviceToken + ) + } +} + +/// Emits one payload-free invalidation per main-loop turn in which the fingerprint moved away from +/// the last state JS was told about. +@MainActor +final class ClerkClientInvalidationTracker { + typealias Scheduler = (@escaping @MainActor () -> Void) -> Void + + private let schedule: Scheduler + private let emit: () -> Void + private var baseline: ClerkClientFingerprint? + private var latest: ClerkClientFingerprint? + private var isFlushScheduled = false + + init( + schedule: @escaping Scheduler = { work in DispatchQueue.main.async { MainActor.assumeIsolated(work) } }, + emit: @escaping () -> Void + ) { + self.schedule = schedule + self.emit = emit + } + + /// Sets the state JS already knows about without emitting. + func reset(to fingerprint: ClerkClientFingerprint?) { + baseline = fingerprint + latest = fingerprint + } + + func observe(_ fingerprint: ClerkClientFingerprint) { + guard baseline != nil else { return } + latest = fingerprint + guard fingerprint != baseline, !isFlushScheduled else { return } + isFlushScheduled = true + schedule { [weak self] in self?.flush() } + } + + /// A token JS wrote itself is not news to JS, so it must not echo back as an invalidation. + func acknowledgeDeviceToken(_ token: String?, current: ClerkClientFingerprint) { + baseline?.deviceToken = token + observe(current) + } + + private func flush() { + isFlushScheduled = false + guard let latest, latest != baseline else { return } + baseline = latest + emit() + } +} + +struct ClerkDeviceTokenErrorDescriptor: Equatable { + let code: String + let message: String +} + +enum ClerkClientSyncError: Error, LocalizedError { + case notConfigured + + var errorDescription: String? { + "Clerk must be configured with configureNative before syncing client state." + } +} + +func clerkSetDeviceTokenErrorDescriptor(_ error: Error) -> ClerkDeviceTokenErrorDescriptor { + switch error { + case ClerkClientSyncError.notConfigured: + return .init(code: "E_NOT_CONFIGURED", message: error.localizedDescription) + case Clerk.DeviceTokenError.emptyToken: + return .init(code: "E_INVALID_DEVICE_TOKEN", message: error.localizedDescription) + case is CancellationError: + return .init(code: "E_CANCELLED", message: "The device token update was cancelled.") + default: + return .init(code: "E_SET_DEVICE_TOKEN_FAILED", message: error.localizedDescription) + } +} diff --git a/packages/expo-native-components/ios/ClerkExpo.podspec b/packages/expo-native-components/ios/ClerkExpo.podspec index 13b4510aa12..104abc556a0 100644 --- a/packages/expo-native-components/ios/ClerkExpo.podspec +++ b/packages/expo-native-components/ios/ClerkExpo.podspec @@ -18,7 +18,7 @@ else end clerk_ios_repo = 'https://github.com/clerk/clerk-ios.git' -clerk_ios_version = '1.5.7' +clerk_ios_branch = 'mike/framework-set-device-token' 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: { :kind => 'branch', :branch => clerk_ios_branch }, products: ['ClerkKit', 'ClerkKitUI'] ) else @@ -52,6 +52,7 @@ Pod::Spec.new do |s| end s.source_files = "ClerkNativeBridge.swift", + "ClerkClientInvalidation.swift", "ClerkAppDelegateSubscriber.swift", "ClerkExpoModule.swift", "ClerkNativeViewHost.swift", diff --git a/packages/expo-native-components/ios/ClerkExpoModule.swift b/packages/expo-native-components/ios/ClerkExpoModule.swift index a1a12679066..fb84e7f59a0 100644 --- a/packages/expo-native-components/ios/ClerkExpoModule.swift +++ b/packages/expo-native-components/ios/ClerkExpoModule.swift @@ -10,13 +10,14 @@ import Foundation public class ClerkExpoModule: Module { private static let nativeAuthFlowChangedEvent = "clerkNativeAuthFlowChanged" private static let nativeClientChangedEvent = "clerkNativeClientChanged" + private static let nativeClientInvalidatedEvent = "clerkNativeClientInvalidated" private static weak var sharedInstance: ClerkExpoModule? public func definition() -> ModuleDefinition { Name("ClerkExpo") - Events(Self.nativeAuthFlowChangedEvent, Self.nativeClientChangedEvent) + Events(Self.nativeAuthFlowChangedEvent, Self.nativeClientChangedEvent, Self.nativeClientInvalidatedEvent) OnCreate { Self.sharedInstance = self @@ -26,6 +27,9 @@ public class ClerkExpoModule: Module { ClerkNativeBridge.setClientChangedEmitter { body in Self.emitClientChanged(body) } + ClerkNativeBridge.setClientInvalidatedEmitter { + Self.emitClientInvalidated() + } } OnDestroy { @@ -33,6 +37,7 @@ public class ClerkExpoModule: Module { Self.sharedInstance = nil ClerkNativeBridge.setAuthFlowChangedEmitter(nil) ClerkNativeBridge.setClientChangedEmitter(nil) + ClerkNativeBridge.setClientInvalidatedEmitter(nil) } } @@ -44,6 +49,51 @@ public class ClerkExpoModule: Module { self.getClientToken(promise: promise) } + AsyncFunction("configureNative") { (publishableKey: String, seedDeviceToken: String?, promise: Promise) in + Task { @MainActor in + do { + try await ClerkNativeBridge.shared.configureNative( + publishableKey: publishableKey, + seedDeviceToken: seedDeviceToken + ) + promise.resolve() + } catch { + promise.reject("E_CONFIGURE_FAILED", error.localizedDescription) + } + } + } + + AsyncFunction("getDeviceToken") { (promise: Promise) in + Task { @MainActor in + promise.resolve(ClerkNativeBridge.shared.getDeviceToken()) + } + } + + AsyncFunction("setDeviceToken") { (token: String?, expected: String?, promise: Promise) in + Task { @MainActor in + do { + let didSet = try await ClerkNativeBridge.shared.setDeviceToken(token, expected: expected) + promise.resolve(didSet) + } catch { + let descriptor = clerkSetDeviceTokenErrorDescriptor(error) + promise.reject(descriptor.code, descriptor.message) + } + } + } + + AsyncFunction("refreshClient") { (promise: Promise) in + Task { @MainActor in + do { + try await ClerkNativeBridge.shared.refreshClient() + promise.resolve() + } catch ClerkClientSyncError.notConfigured { + promise.reject("E_NOT_CONFIGURED", ClerkClientSyncError.notConfigured.localizedDescription) + } catch { + promise.reject("E_REFRESH_CLIENT_FAILED", error.localizedDescription) + } + } + } + AsyncFunction("getAuthFlowState") { (promise: Promise) in self.getAuthFlowState(promise: promise) } @@ -301,6 +351,16 @@ public class ClerkExpoModule: Module { } } + static func emitClientInvalidated() { + guard let instance = sharedInstance else { + return + } + + DispatchQueue.main.async { [weak instance] in + instance?.sendEvent(Self.nativeClientInvalidatedEvent, [:]) + } + } + static func emitAuthFlowChanged(_ body: [String: Any]? = nil) { let eventBody = body ?? [:] diff --git a/packages/expo-native-components/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift index bcb20cb8bfc..0551e609f32 100644 --- a/packages/expo-native-components/ios/ClerkNativeBridge.swift +++ b/packages/expo-native-components/ios/ClerkNativeBridge.swift @@ -398,6 +398,7 @@ func userProfileCustomPageLabel( private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events") private var clerkNativeAuthFlowChangedEmitter: (([String: Any]?) -> Void)? private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)? +private var clerkNativeClientInvalidatedEmitter: (() -> Void)? struct ClerkNativeErrorDescriptor { let code: String @@ -446,6 +447,8 @@ final class ClerkNativeBridge { private var lastObservedAuthFlowState: AuthFlowStateSnapshot? private var configurationDepth = 0 private var jsOriginatedClientSyncDepth = 0 + private var clientInvalidationGeneration = 0 + private var clientInvalidationTracker: ClerkClientInvalidationTracker? private var pendingURL: URL? private var shouldFlushPendingURL = false @@ -477,23 +480,26 @@ final class ClerkNativeBridge { return Bundle.main.bundleIdentifier } + @MainActor + private func endConfiguration() { + lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil + let authFlowState = Self.authFlowStateSnapshot() + lastObservedAuthFlowState = authFlowState + configurationDepth = max(0, configurationDepth - 1) + Self.emitAuthFlowChanged(Self.authFlowStatePayload(authFlowState)) + + // Overlapping calls can finish out of order, so replay once the last one settles and any + // of them succeeded. A batch where every call threw keeps the URL for the next attempt. + if configurationDepth == 0, shouldFlushPendingURL { + shouldFlushPendingURL = false + flushPendingURL() + } + } + @MainActor func configure(publishableKey: String, bearerToken: String? = nil) async throws { 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)) - - // Overlapping calls can finish out of order, so replay once the last one settles and any - // of them succeeded. A batch where every call threw keeps the URL for the next attempt. - if configurationDepth == 0, shouldFlushPendingURL { - shouldFlushPendingURL = false - flushPendingURL() - } - } + defer { endConfiguration() } loadThemes() @@ -539,6 +545,120 @@ final class ClerkNativeBridge { shouldFlushPendingURL = true } + /// Configures ClerkKit without waiting for the client to load. ClerkKit's stored device token + /// wins; `seedDeviceToken` is only adopted when ClerkKit has none. + @MainActor + func configureNative(publishableKey: String, seedDeviceToken: String?) async throws { + configurationDepth += 1 + defer { endConfiguration() } + + loadThemes() + + var didConfigure = true + if Self.shouldReconfigure(for: publishableKey) { + try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + Self.configuredPublishableKey = publishableKey + startClientObserver(reset: true) + startAuthFlowObserver(reset: true) + } else if Self.clerkConfigured { + didConfigure = false + startClientObserver() + startAuthFlowObserver() + } else { + Self.clerkConfigured = true + Self.configuredPublishableKey = publishableKey + Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + startClientObserver() + startAuthFlowObserver() + } + + let didAdoptSeed = try await Self.adoptSeedDeviceTokenIfNeeded(seedDeviceToken) + startClientInvalidationObserver(reset: didConfigure) + if didAdoptSeed { + // A client load started before the seed was stored may have been fenced off by the token change. + Task { @MainActor in + _ = try? await Clerk.shared.refreshClient() + } + } + if didConfigure { + Self.postConfiguredNotification() + } + shouldFlushPendingURL = true + } + + @MainActor + private static func adoptSeedDeviceTokenIfNeeded(_ seedDeviceToken: String?) async throws -> Bool { + guard let seed = seedDeviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !seed.isEmpty, + Clerk.shared.deviceToken == nil + else { + return false + } + return try await Clerk.shared.setDeviceToken(seed, expected: nil) + } + + @MainActor + func getDeviceToken() -> String? { + guard Self.clerkConfigured else { return nil } + return Clerk.shared.deviceToken + } + + @MainActor + func setDeviceToken(_ token: String?, expected: String?) async throws -> Bool { + guard Self.clerkConfigured else { throw ClerkClientSyncError.notConfigured } + + let didSet = try await Clerk.shared.setDeviceToken(token, expected: expected) + let fingerprint = Self.clientFingerprint() + if didSet { + clientInvalidationTracker?.acknowledgeDeviceToken(fingerprint.deviceToken, current: fingerprint) + } else { + clientInvalidationTracker?.observe(fingerprint) + } + return didSet + } + + @MainActor + func refreshClient() async throws { + guard Self.clerkConfigured else { throw ClerkClientSyncError.notConfigured } + defer { recordClientFingerprint() } + _ = try await Clerk.shared.refreshClient() + } + + @MainActor + private func startClientInvalidationObserver(reset: Bool) { + let tracker = clientInvalidationTracker ?? ClerkClientInvalidationTracker { Self.emitClientInvalidated() } + clientInvalidationTracker = tracker + tracker.reset(to: Self.clientFingerprint()) + + guard reset || clientInvalidationGeneration == 0 else { return } + clientInvalidationGeneration += 1 + observeClientInvalidation(generation: clientInvalidationGeneration) + } + + @MainActor + private func observeClientInvalidation(generation: Int) { + withObservationTracking { + _ = Self.clientFingerprint() + } onChange: { [weak self] in + Task { @MainActor [weak self] in + await Task.yield() + + guard let self, generation == self.clientInvalidationGeneration else { return } + self.recordClientFingerprint() + self.observeClientInvalidation(generation: generation) + } + } + } + + @MainActor + private func recordClientFingerprint() { + clientInvalidationTracker?.observe(Self.clientFingerprint()) + } + + @MainActor + private static func clientFingerprint() -> ClerkClientFingerprint { + ClerkClientFingerprint(client: Clerk.shared.client, deviceToken: Clerk.shared.deviceToken) + } + @MainActor private func flushPendingURL() { guard let url = pendingURL else { return } @@ -1146,6 +1266,19 @@ final class ClerkNativeBridge { } } + static func setClientInvalidatedEmitter(_ emitter: (() -> Void)?) { + clerkNativeClientEventQueue.sync { + clerkNativeClientInvalidatedEmitter = emitter + } + } + + static func emitClientInvalidated() { + let emitter = clerkNativeClientEventQueue.sync { + clerkNativeClientInvalidatedEmitter + } + emitter?() + } + static func setAuthFlowChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) { clerkNativeClientEventQueue.sync { clerkNativeAuthFlowChangedEmitter = emitter diff --git a/packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift b/packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift new file mode 100644 index 00000000000..d5bc3ba9209 --- /dev/null +++ b/packages/expo-native-components/ios/Tests/ClerkClientInvalidationTests.swift @@ -0,0 +1,143 @@ +import XCTest +@_spi(FrameworkIntegration) import ClerkKit +@testable import ClerkExpo + +@MainActor +final class ClerkClientInvalidationTests: XCTestCase { + private var scheduled: [@MainActor () -> Void] = [] + private var emitCount = 0 + + private func makeTracker() -> ClerkClientInvalidationTracker { + ClerkClientInvalidationTracker( + schedule: { [unowned self] work in self.scheduled.append(work) }, + emit: { [unowned self] in self.emitCount += 1 } + ) + } + + private func runLoopTurn() { + let work = scheduled + scheduled.removeAll() + work.forEach { $0() } + } + + private func fingerprint( + clientId: String? = "client_1", + sessions: [ClerkClientFingerprint.SessionState] = [.init(id: "sess_1", status: "active")], + userUpdatedAt: Double? = 1, + deviceToken: String? = "token_1" + ) -> ClerkClientFingerprint { + ClerkClientFingerprint( + clientId: clientId, + lastActiveSessionId: sessions.first?.id, + sessions: sessions, + activeUserId: sessions.isEmpty ? nil : "user_1", + activeUserUpdatedAt: sessions.isEmpty ? nil : userUpdatedAt, + deviceToken: deviceToken + ) + } + + override func setUp() { + super.setUp() + scheduled = [] + emitCount = 0 + } + + func testDoesNotEmitBeforeBaselineOrForUnchangedState() { + let tracker = makeTracker() + tracker.observe(fingerprint()) + XCTAssertTrue(scheduled.isEmpty) + + tracker.reset(to: fingerprint()) + tracker.observe(fingerprint()) + runLoopTurn() + XCTAssertEqual(emitCount, 0) + } + + func testEmitsForEachFingerprintField() { + let changes = [ + fingerprint(clientId: "client_2"), + fingerprint(sessions: [.init(id: "sess_1", status: "ended")]), + fingerprint(sessions: []), + fingerprint(userUpdatedAt: 2), + fingerprint(deviceToken: "token_2"), + ] + + for change in changes { + let tracker = makeTracker() + tracker.reset(to: fingerprint()) + emitCount = 0 + tracker.observe(change) + runLoopTurn() + XCTAssertEqual(emitCount, 1, "\(change)") + } + } + + func testCoalescesChangesWithinOneLoopTurn() { + let tracker = makeTracker() + tracker.reset(to: fingerprint()) + + tracker.observe(fingerprint(deviceToken: "token_2")) + tracker.observe(fingerprint(clientId: "client_2", deviceToken: "token_2")) + tracker.observe(fingerprint(clientId: "client_3", deviceToken: "token_3")) + XCTAssertEqual(scheduled.count, 1) + runLoopTurn() + XCTAssertEqual(emitCount, 1) + + tracker.observe(fingerprint(clientId: "client_3", deviceToken: "token_3")) + runLoopTurn() + XCTAssertEqual(emitCount, 1) + + tracker.observe(fingerprint(clientId: "client_4", deviceToken: "token_3")) + runLoopTurn() + XCTAssertEqual(emitCount, 2) + } + + func testChangeRevertedWithinOneLoopTurnDoesNotEmit() { + let tracker = makeTracker() + tracker.reset(to: fingerprint()) + + tracker.observe(fingerprint(clientId: "client_2")) + tracker.observe(fingerprint()) + runLoopTurn() + XCTAssertEqual(emitCount, 0) + } + + func testAcknowledgedDeviceTokenDoesNotEcho() { + let tracker = makeTracker() + tracker.reset(to: fingerprint()) + + tracker.acknowledgeDeviceToken("token_2", current: fingerprint(deviceToken: "token_2")) + runLoopTurn() + XCTAssertEqual(emitCount, 0) + + tracker.acknowledgeDeviceToken("token_3", current: fingerprint(clientId: "client_2", deviceToken: "token_3")) + runLoopTurn() + XCTAssertEqual(emitCount, 1) + } + + func testSetDeviceTokenErrorCodes() { + XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(ClerkClientSyncError.notConfigured).code, "E_NOT_CONFIGURED") + XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(Clerk.DeviceTokenError.emptyToken).code, "E_INVALID_DEVICE_TOKEN") + XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(CancellationError()).code, "E_CANCELLED") + XCTAssertEqual( + clerkSetDeviceTokenErrorDescriptor(Clerk.DeviceTokenError.updateRejected).code, + "E_SET_DEVICE_TOKEN_FAILED" + ) + } + + func testSyncFunctionsRejectOrReturnNilBeforeConfiguration() async { + XCTAssertNil(ClerkNativeBridge.shared.getDeviceToken()) + do { + _ = try await ClerkNativeBridge.shared.setDeviceToken("token", expected: nil) + XCTFail("Expected setDeviceToken to throw") + } catch { + XCTAssertEqual(clerkSetDeviceTokenErrorDescriptor(error).code, "E_NOT_CONFIGURED") + } + do { + try await ClerkNativeBridge.shared.refreshClient() + XCTFail("Expected refreshClient to throw") + } catch { + XCTAssertTrue(error is ClerkClientSyncError) + } + } +} diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index d5c59ceb1c9..51d214fc53c 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -1,8 +1,12 @@ import { requireOptionalNativeModule } from 'expo'; -import type { NativeAuthFlowModule, NativeBiometricCredentialModule } from './NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeBiometricCredentialModule, + NativeClientSyncModule, +} from './NativeClerkModule.types'; -interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule { +interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, Partial { // 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 7e71393a9be..c999c57d6c4 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -1,8 +1,12 @@ import { requireOptionalNativeModule } from 'expo'; -import type { NativeAuthFlowModule, NativeBiometricCredentialModule } from './NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeBiometricCredentialModule, + NativeClientSyncModule, +} from './NativeClerkModule.types'; -export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule { +export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule, Partial { // 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 index fa3aa35b6e2..fb99d305e33 100644 --- a/packages/expo/src/specs/NativeClerkModule.types.ts +++ b/packages/expo/src/specs/NativeClerkModule.types.ts @@ -11,6 +11,17 @@ export type NativeAuthFlowModule = { getAuthFlowState(): Promise; }; +/** + * Native storage owns the device token. JS reads it before each FAPI request, writes rotated tokens + * back with compare-and-set, and refetches its own client on `clerkNativeClientInvalidated`. + */ +export type NativeClientSyncModule = { + configureNative(publishableKey: string, seedDeviceToken: string | null): Promise; + getDeviceToken(): Promise; + setDeviceToken(token: string | null, expected: string | null): Promise; + refreshClient(): Promise; +}; + export type NativeBiometricCredential = { id: string; object: 'trusted_device'; diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index 11751ec4114..e81c3aabee9 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -1,7 +1,11 @@ import { Platform } from 'react-native'; import NativeClerkModule from '../specs/NativeClerkModule'; -import type { NativeAuthFlowModule, NativeBiometricCredentialModule } from '../specs/NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeBiometricCredentialModule, + NativeClientSyncModule, +} from '../specs/NativeClerkModule.types'; export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; @@ -15,7 +19,7 @@ export type ClerkExpoNativeModule = { didChangeClient: boolean, didChangeDeviceToken: boolean, ): Promise; -} & Partial; +} & Partial; function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { if (!module || typeof module !== 'object') {