diff --git a/.changeset/quiet-ravens-remember.md b/.changeset/quiet-ravens-remember.md new file mode 100644 index 00000000000..bffab3586ec --- /dev/null +++ b/.changeset/quiet-ravens-remember.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Fix Expo native Clerk components and `useNativeSession()` staying stale when authentication changes between the JavaScript and native SDKs. JS-owned sign-in now hydrates native components on cold start, and sign-out from either JS or native updates the other side. 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 9ef266fb523..bac89b05081 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 @@ -41,7 +41,8 @@ class ClerkExpoModule : Module() { private var sharedInstance: ClerkExpoModule? = null fun emitRefreshClient() { - sharedInstance?.sendEvent("refreshClient", emptyMap()) + val instance = sharedInstance ?: return + instance.sendEvent("refreshClient", instance.currentAuthStatePayload()) } } @@ -77,6 +78,10 @@ class ClerkExpoModule : Module() { AsyncFunction("refreshClient") { promise: Promise -> refreshClient(promise) } + + AsyncFunction("signOut") { sessionId: String?, promise: Promise -> + signOut(sessionId, promise) + } } private val reactContext: Context? @@ -101,6 +106,44 @@ class ClerkExpoModule : Module() { } } + private fun currentAuthStatePayload(): Map { + val session = Clerk.session + val user = Clerk.user + val result = mutableMapOf( + "sessionId" to session?.id, + "clientToken" to try { + Clerk.getDeviceToken() + } catch (e: Exception) { + debugLog(TAG, "currentAuthStatePayload - getDeviceToken failed: ${e.message}") + null + } + ) + + result["session"] = session?.let { + mapOf( + "id" to it.id, + "status" to it.status.name, + "userId" to it.user?.id + ) + } + + result["user"] = user?.let { + val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } + val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId } + + mapOf( + "id" to it.id, + "firstName" to it.firstName, + "lastName" to it.lastName, + "imageUrl" to it.imageUrl, + "primaryEmailAddress" to primaryEmail?.emailAddress, + "primaryPhoneNumber" to primaryPhone?.phoneNumber + ) + } + + return result + } + // MARK: - configure private fun configure(pubKey: String, bearerToken: String?, promise: Promise) { @@ -255,34 +298,7 @@ class ClerkExpoModule : Module() { return } - val session = Clerk.session - val user = Clerk.user - - val result = mutableMapOf() - - session?.let { - result["session"] = mapOf( - "id" to it.id, - "status" to it.status.name, - "userId" to it.user?.id - ) - } - - user?.let { - val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } - val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId } - - result["user"] = mapOf( - "id" to it.id, - "firstName" to it.firstName, - "lastName" to it.lastName, - "imageUrl" to it.imageUrl, - "primaryEmailAddress" to primaryEmail?.emailAddress, - "primaryPhoneNumber" to primaryPhone?.phoneNumber - ) - } - - promise.resolve(result) + promise.resolve(currentAuthStatePayload()) } // MARK: - getClientToken @@ -324,6 +340,33 @@ class ClerkExpoModule : Module() { } } + // MARK: - signOut + + private fun signOut(sessionId: String?, promise: Promise) { + if (!Clerk.isInitialized.value) { + promise.resolve(null) + return + } + + coroutineScope.launch { + try { + when (val result = Clerk.auth.signOut(sessionId = sessionId)) { + is ClerkResult.Failure -> promise.reject( + "E_SIGN_OUT_FAILED", + result.error?.firstMessage() ?: result.throwable?.message ?: "Sign-out failed", + null + ) + is ClerkResult.Success -> { + emitRefreshClient() + promise.resolve(null) + } + } + } catch (e: Exception) { + promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign-out failed", e) + } + } + } + // MARK: - Theme Loading private fun loadThemeFromAssets(context: Context) { diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m index a0bb2e88f08..672bc75f51e 100644 --- a/packages/expo/ios/ClerkExpoModule.m +++ b/packages/expo/ios/ClerkExpoModule.m @@ -17,4 +17,8 @@ @interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter) RCT_EXTERN_METHOD(refreshClient:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(signOut:(NSString *)sessionId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + @end diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 462fef7e812..f53fc4b22d5 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -13,7 +13,13 @@ public enum ClerkNativeViewEvent: String { } // Global registry for the app-target native bridge (set by the app target at startup) -public var clerkNativeBridge: ClerkNativeBridgeProtocol? +public var clerkNativeBridge: ClerkNativeBridgeProtocol? { + didSet { + if clerkNativeBridge != nil { + emitClerkNativeBridgeReady() + } + } +} // Protocol that the app target implements to provide Clerk SDK operations and SwiftUI views. public protocol ClerkNativeBridgeProtocol { @@ -25,8 +31,37 @@ public protocol ClerkNativeBridgeProtocol { // SDK operations func configure(publishableKey: String, bearerToken: String?) async throws func getSession() async -> [String: Any]? - func getClientToken() -> String? + func getClientToken() async -> String? func refreshClient() async throws + func signOut(sessionId: String?) async throws +} + +public protocol ClerkNativeBridgeReadyObserver: AnyObject { + func clerkNativeBridgeDidBecomeReady() +} + +private let clerkNativeBridgeReadyObservers = NSHashTable.weakObjects() + +public func addClerkNativeBridgeReadyObserver(_ observer: ClerkNativeBridgeReadyObserver) { + clerkNativeBridgeReadyObservers.add(observer) +} + +public func removeClerkNativeBridgeReadyObserver(_ observer: ClerkNativeBridgeReadyObserver) { + clerkNativeBridgeReadyObservers.remove(observer) +} + +public func emitClerkNativeBridgeReady() { + let notifyObservers = { + for observer in clerkNativeBridgeReadyObservers.allObjects { + (observer as? ClerkNativeBridgeReadyObserver)?.clerkNativeBridgeDidBecomeReady() + } + } + + if Thread.isMainThread { + notifyObservers() + } else { + DispatchQueue.main.async(execute: notifyObservers) + } } // MARK: - Module @@ -60,9 +95,9 @@ class ClerkExpoModule: RCTEventEmitter { /// Emits a refreshClient event to JS from anywhere in the native layer. /// Used by native views to ask ClerkProvider to reload JS client state. - static func emitRefreshClient() { + static func emitRefreshClient(_ body: [String: Any]? = nil) { guard _hasListeners, let instance = sharedInstance else { return } - instance.sendEvent(withName: "refreshClient", body: nil) + instance.sendEvent(withName: "refreshClient", body: body ?? [:]) } // MARK: - configure @@ -110,7 +145,10 @@ class ClerkExpoModule: RCTEventEmitter { return } - resolve(bridge.getClientToken()) + Task { + let token = await bridge.getClientToken() + resolve(token) + } } // MARK: - refreshClient @@ -132,9 +170,29 @@ class ClerkExpoModule: RCTEventEmitter { } } + // MARK: - signOut + + @objc func signOut(_ sessionId: String?, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard let bridge = clerkNativeBridge else { + resolve(nil) + return + } + + Task { + do { + try await bridge.signOut(sessionId: sessionId) + resolve(nil) + } catch { + reject("E_SIGN_OUT_FAILED", error.localizedDescription, error) + } + } + } + } /// Requests that ClerkProvider reload the JS client from native client state. -public func emitClerkNativeRefreshClient() { - ClerkExpoModule.emitRefreshClient() +public func emitClerkNativeRefreshClient(_ body: [String: Any]? = nil) { + ClerkExpoModule.emitRefreshClient(body) } diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index 41171f4a760..608a1956f5b 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -1,12 +1,11 @@ // ClerkNativeBridge - Provides app-target Clerk SDK operations and SwiftUI view controllers to ClerkExpo. // This file is injected into the app target by the config plugin. -// It uses `import ClerkKit` (SPM) which is only accessible from the app target. +// It uses the ClerkKit Swift package, which is only accessible from the app target. import UIKit import SwiftUI import Observation -import Security -import ClerkKit +@_spi(FrameworkIntegration) import ClerkKit import ClerkKitUI import ClerkExpo // Import the pod to access ClerkNativeBridgeProtocol @@ -25,17 +24,19 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { var darkTheme: ClerkTheme? private var clientObservationGeneration = 0 - private var lastObservedClient: Client? - - private enum KeychainKey { - static let jsClientJWT = "__clerk_client_jwt" - static let nativeDeviceToken = "clerkDeviceToken" - static let cachedClient = "cachedClient" - static let cachedEnvironment = "cachedEnvironment" - } + private var lastObservedAuthState: AuthStateSnapshot? + private var authEventObservationTask: Swift.Task? private init() {} + private struct AuthStateSnapshot: Equatable { + let clientId: String? + let sessionId: String? + let userId: String? + let sessionIds: [String] + let deviceToken: String? + } + /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first /// (for extension apps sharing a keychain group), then falling back to the bundle identifier. private static var keychainService: String? { @@ -45,11 +46,6 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { return Bundle.main.bundleIdentifier } - private static var keychain: ExpoKeychain? { - guard let service = keychainService, !service.isEmpty else { return nil } - return ExpoKeychain(service: service) - } - // Register this app-target bridge with the ClerkExpo module. @MainActor public static func register() { shared.loadThemes() @@ -63,30 +59,20 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Self.clerkConfigured = true Self.configuredPublishableKey = publishableKey startClientObserver(reset: true) + startAuthEventObserver(reset: true) - Self.syncTokenState(bearerToken: bearerToken) - if !(bearerToken?.isEmpty ?? true) { - _ = try? await Clerk.shared.refreshClient() - } - - await Self.waitForLoadedSession() - return - } - - Self.syncTokenState(bearerToken: bearerToken) - - // If already configured with a new bearer token, refresh the client - // to pick up the session associated with the device token we just wrote. - // Clerk.configure() is idempotent for the same publishable key, so use refreshClient(). - if Self.shouldRefreshConfiguredClient(for: bearerToken) { - startClientObserver() - _ = try? await Clerk.shared.refreshClient() - await Self.waitForLoadedSession() + let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) + emitClerkNativeBridgeReady() return } if Self.clerkConfigured { startClientObserver() + startAuthEventObserver() + let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) + emitClerkNativeBridgeReady() return } @@ -94,8 +80,11 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { Self.configuredPublishableKey = publishableKey Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) startClientObserver() + startAuthEventObserver() - await Self.waitForLoadedSession() + let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken) + await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession) + emitClerkNativeBridgeReady() } @MainActor @@ -104,24 +93,24 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { clientObservationGeneration += 1 let generation = clientObservationGeneration - lastObservedClient = Clerk.shared.client + lastObservedAuthState = Self.authStateSnapshot() observeClient(generation: generation) } @MainActor private func observeClient(generation: Int) { withObservationTracking { - _ = Clerk.shared.client + _ = Self.authStateSnapshot() } onChange: { [weak self] in Task { @MainActor [weak self] in await Task.yield() guard let self, generation == self.clientObservationGeneration else { return } - let newClient = Clerk.shared.client - if newClient != self.lastObservedClient { - self.lastObservedClient = newClient - emitClerkNativeRefreshClient() + let newAuthState = Self.authStateSnapshot() + if newAuthState != self.lastObservedAuthState { + self.lastObservedAuthState = newAuthState + emitClerkNativeRefreshClient(Self.authStatePayload()) } self.observeClient(generation: generation) @@ -129,30 +118,90 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } - private static func syncTokenState(bearerToken: String?) { - // Sync JS SDK's client token to native keychain so both SDKs share the same client. - // This handles the case where the user signed in via JS SDK but the native SDK - // has no device token (e.g., after app reinstall or first launch). - if let token = bearerToken, !token.isEmpty { - let existingToken = readNativeDeviceToken() - writeNativeDeviceToken(token) - - // If the device token changed (or didn't exist), clear stale cached client/environment. - // A previous launch may have cached an anonymous client (no device token), and the - // SDK would send both the new device token AND the stale client ID in API requests, - // causing a 400 error. Clearing the cache forces a fresh client fetch using only - // the device token. - if existingToken != token { - clearCachedClerkData() + @MainActor + private func startAuthEventObserver(reset: Bool = false) { + if reset { + authEventObservationTask?.cancel() + authEventObservationTask = nil + } + + guard authEventObservationTask == nil else { return } + + authEventObservationTask = Swift.Task { @MainActor [weak self] in + for await event in Clerk.shared.auth.events { + await self?.handleAuthEvent(event) } - return } + } - syncJSTokenToNativeKeychainIfNeeded() + @MainActor + private func handleAuthEvent(_ event: AuthEvent) async { + switch event { + case .sessionChanged(_, _), .signInCompleted(_), .signUpCompleted(_): + emitClerkNativeRefreshClient(Self.authStatePayload()) + case .signedOut(_), .accountDeleted: + emitClerkNativeRefreshClient(Self.signedOutAuthStatePayload()) + Swift.Task { @MainActor in + do { + _ = try await Clerk.shared.refreshClient() + } catch { + // The auth event still represents the latest user action; emit below so + // JS consumers can clear stale state even if the follow-up refresh fails. + } + emitClerkNativeRefreshClient(Self.authStatePayload()) + } + case .signInNeedsContinuation(_), .signUpNeedsContinuation(_), .tokenRefreshed(_): + break + } + } + + @MainActor + private static func authStateSnapshot() -> AuthStateSnapshot { + let client = Clerk.shared.client + let session = Clerk.shared.session + + return AuthStateSnapshot( + clientId: client?.id, + sessionId: session?.id, + userId: Clerk.shared.user?.id, + sessionIds: client?.sessions.map(\.id) ?? [], + deviceToken: Clerk.shared.deviceToken + ) + } + + @MainActor + private static func authStatePayload() -> [String: Any] { + var payload: [String: Any] = [:] + payload["sessionId"] = Clerk.shared.session?.id ?? NSNull() + payload["clientToken"] = Clerk.shared.deviceToken ?? NSNull() + + if let session = Clerk.shared.session { + payload.merge(sessionPayload(from: session, user: session.user ?? Clerk.shared.user)) { _, new in new } + } else { + payload["session"] = NSNull() + payload["user"] = NSNull() + } + + return payload + } + + @MainActor + private static func signedOutAuthStatePayload() -> [String: Any] { + return [ + "sessionId": NSNull(), + "clientToken": Clerk.shared.deviceToken ?? NSNull(), + "session": NSNull(), + "user": NSNull(), + ] } - private static func shouldRefreshConfiguredClient(for bearerToken: String?) -> Bool { - clerkConfigured && !(bearerToken?.isEmpty ?? true) + @MainActor + private static func syncTokenState(bearerToken: String?) async throws -> Bool { + guard let token = bearerToken, !token.isEmpty else { + return Clerk.shared.deviceToken != nil + } + _ = try await Clerk.shared.updateDeviceToken(token) + return true } private static func shouldReconfigure(for publishableKey: String) -> Bool { @@ -179,39 +228,26 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } - /// Copies the JS SDK's client JWT from expo-secure-store to the native SDK's - /// keychain entry, but only if the native SDK doesn't already have a device token. - /// Both expo-secure-store and the native Clerk SDK use the iOS Keychain with the - /// bundle identifier as the service name, making cross-SDK token sharing possible. - private static func syncJSTokenToNativeKeychainIfNeeded() { - guard let keychain else { return } - guard keychain.string(forKey: KeychainKey.nativeDeviceToken) == nil else { return } - guard let jsToken = keychain.string(forKey: KeychainKey.jsClientJWT), !jsToken.isEmpty else { return } - - keychain.set(jsToken, forKey: KeychainKey.nativeDeviceToken) - } - - /// Reads the native device token from keychain, if present. - private static func readNativeDeviceToken() -> String? { - keychain?.string(forKey: KeychainKey.nativeDeviceToken) - } - - /// Clears stale cached client and environment data from keychain. - /// This prevents the native SDK from loading a stale anonymous client - /// during initialization, which would conflict with a newly-synced device token. - private static func clearCachedClerkData() { - keychain?.delete(KeychainKey.cachedClient) - keychain?.delete(KeychainKey.cachedEnvironment) + @MainActor + private static func waitForLoadedClient() async { + for _ in 0.. String? { - Self.readNativeDeviceToken() + @MainActor + public func getClientToken() async -> String? { + guard Self.clerkConfigured else { return nil } + return Clerk.shared.deviceToken } // MARK: - Inline View Creation @@ -221,7 +257,9 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { - makeHostingController( + guard Self.clerkConfigured else { return nil } + + return makeHostingController( rootView: ClerkInlineAuthWrapperView( mode: Self.authMode(from: mode), dismissible: dismissible, @@ -236,7 +274,9 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { dismissible: Bool, onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void ) -> UIViewController? { - makeHostingController( + guard Self.clerkConfigured else { return nil } + + return makeHostingController( rootView: ClerkInlineProfileWrapperView( dismissible: dismissible, lightTheme: lightTheme, @@ -247,7 +287,9 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } public func makeUserButtonViewController() -> UIViewController? { - makeHostingController( + guard Self.clerkConfigured else { return nil } + + return makeHostingController( rootView: ClerkInlineUserButtonWrapperView( lightTheme: lightTheme, darkTheme: darkTheme @@ -267,7 +309,18 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { public func refreshClient() async throws { guard Self.clerkConfigured else { return } _ = try await Clerk.shared.refreshClient() - await Self.waitForLoadedSession() + await Self.waitForLoadedClient() + emitClerkNativeBridgeReady() + } + + @MainActor + public func signOut(sessionId: String?) async throws { + guard Self.clerkConfigured else { return } + try await Clerk.shared.auth.signOut(sessionId: sessionId) + _ = try await Clerk.shared.refreshClient() + await Self.waitForLoadedClient() + emitClerkNativeRefreshClient(Self.authStatePayload()) + emitClerkNativeBridgeReady() } private static func authMode(from mode: String) -> AuthView.Mode { @@ -410,61 +463,6 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol { } } -private struct ExpoKeychain { - private let service: String - - init(service: String) { - self.service = service - } - - func string(forKey key: String) -> String? { - guard let data = data(forKey: key) else { return nil } - return String(data: data, encoding: .utf8) - } - - func set(_ value: String, forKey key: String) { - guard let data = value.data(using: .utf8) else { return } - - var addQuery = baseQuery(for: key) - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - addQuery[kSecValueData as String] = data - - let status = SecItemAdd(addQuery as CFDictionary, nil) - if status == errSecDuplicateItem { - let attributes: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - SecItemUpdate(baseQuery(for: key) as CFDictionary, attributes as CFDictionary) - } - } - - func delete(_ key: String) { - SecItemDelete(baseQuery(for: key) as CFDictionary) - } - - private func data(forKey key: String) -> Data? { - var query = baseQuery(for: key) - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var result: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { - return nil - } - - return result as? Data - } - - private func baseQuery(for key: String) -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - } -} - // MARK: - Inline User Button Wrapper (for embedded rendering) struct ClerkInlineUserButtonWrapperView: View { diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo/ios/ClerkNativeViewHost.swift index 58385486eb3..445aa429f78 100644 --- a/packages/expo/ios/ClerkNativeViewHost.swift +++ b/packages/expo/ios/ClerkNativeViewHost.swift @@ -1,6 +1,6 @@ import UIKit -public class ClerkNativeViewHost: UIView { +public class ClerkNativeViewHost: UIView, ClerkNativeBridgeReadyObserver { private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) private var hasInitialized: Bool = false @@ -19,6 +19,7 @@ public class ClerkNativeViewHost: UIView { if hasInitialized { hostedViewDidDetachFromWindow() } + removeClerkNativeBridgeReadyObserver(self) hostingCoordinator.detach() hasInitialized = false return @@ -26,6 +27,7 @@ public class ClerkNativeViewHost: UIView { guard !hasInitialized else { return } hasInitialized = true + addClerkNativeBridgeReadyObserver(self) hostedViewDidAttachToWindow() updateHostedView() } @@ -49,6 +51,10 @@ public class ClerkNativeViewHost: UIView { func hostedViewDidDetachFromWindow() {} + public func clerkNativeBridgeDidBecomeReady() { + setNeedsHostedViewUpdate() + } + private func updateHostedView() { guard let controller = makeHostedController() else { return } hostingCoordinator.attach(controller) diff --git a/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts new file mode 100644 index 00000000000..8247cc87413 --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useNativeClientEvents.test.ts @@ -0,0 +1,81 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { addNativeSessionListener, type NativeSessionSnapshot } from '../nativeSessionEvents'; +import { useNativeClientEvents } from '../useNativeClientEvents'; + +const mocks = vi.hoisted(() => { + return { + addListener: vi.fn(), + nativeListener: undefined as ((snapshot?: NativeSessionSnapshot) => void) | undefined, + remove: vi.fn(), + }; +}); + +vi.mock('react-native', () => { + return { + NativeEventEmitter: vi.fn().mockImplementation(() => ({ + addListener: mocks.addListener, + })), + Platform: { + OS: 'ios', + }, + }; +}); + +vi.mock('../../utils/native-module', () => { + return { + ClerkExpoModule: {}, + isNativeSupported: true, + }; +}); + +describe('useNativeClientEvents', () => { + beforeEach(() => { + mocks.nativeListener = undefined; + mocks.remove.mockReset(); + mocks.addListener.mockReset(); + mocks.addListener.mockImplementation((_eventName, listener) => { + mocks.nativeListener = listener; + return { remove: mocks.remove }; + }); + }); + + afterEach(() => { + cleanup(); + }); + + test('forwards native refresh payloads to native session listeners', async () => { + const sessionSnapshots: Array = []; + const removeNativeSessionListener = addNativeSessionListener(snapshot => { + sessionSnapshots.push(snapshot); + }); + + const { result, unmount } = renderHook(() => useNativeClientEvents()); + + act(() => { + mocks.nativeListener?.({ + sessionId: 'sess_123', + clientToken: 'client-token', + user: { id: 'user_123' }, + }); + }); + + await waitFor(() => { + expect(result.current.nativeClientEvent?.sessionId).toBe('sess_123'); + }); + + expect(result.current.nativeClientEvent?.clientToken).toBe('client-token'); + expect(result.current.nativeClientEvent?.user?.id).toBe('user_123'); + expect(sessionSnapshots).toEqual([ + { + sessionId: 'sess_123', + clientToken: 'client-token', + user: { id: 'user_123' }, + }, + ]); + + removeNativeSessionListener(); + unmount(); + }); +}); diff --git a/packages/expo/src/hooks/__tests__/useNativeSession.test.ts b/packages/expo/src/hooks/__tests__/useNativeSession.test.ts new file mode 100644 index 00000000000..ee7e9c4120a --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useNativeSession.test.ts @@ -0,0 +1,154 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { notifyNativeSessionChanged } from '../nativeSessionEvents'; +import { useNativeSession } from '../useNativeSession'; + +const mocks = vi.hoisted(() => { + return { + getSession: vi.fn(), + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + OS: 'ios', + }, + }; +}); + +vi.mock('../../specs/NativeClerkModule', () => { + return { + default: { + configure: vi.fn(), + getSession: mocks.getSession, + getClientToken: vi.fn(), + refreshClient: vi.fn(), + }, + }; +}); + +describe('useNativeSession', () => { + beforeEach(() => { + mocks.getSession.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + test('reads the native session on mount', async () => { + mocks.getSession.mockResolvedValue({ + sessionId: 'sess_123', + user: { id: 'user_123' }, + }); + + const { result } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isAvailable).toBe(true); + expect(result.current.isSignedIn).toBe(true); + expect(result.current.sessionId).toBe('sess_123'); + expect(result.current.user?.id).toBe('user_123'); + }); + + test('refreshes when the native session changes', async () => { + mocks.getSession.mockResolvedValueOnce(null).mockResolvedValueOnce({ + sessionId: 'sess_456', + user: { id: 'user_456' }, + }); + + const { result } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.isSignedIn).toBe(false); + + act(() => { + notifyNativeSessionChanged(); + }); + + await waitFor(() => { + expect(result.current.sessionId).toBe('sess_456'); + }); + + expect(result.current.isSignedIn).toBe(true); + expect(result.current.user?.id).toBe('user_456'); + }); + + test('applies native session event payload without fetching the session', async () => { + mocks.getSession.mockResolvedValue(null); + + const { result } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + mocks.getSession.mockClear(); + + act(() => { + notifyNativeSessionChanged({ + sessionId: 'sess_789', + user: { id: 'user_789' }, + }); + }); + + expect(result.current.isSignedIn).toBe(true); + expect(result.current.sessionId).toBe('sess_789'); + expect(result.current.user?.id).toBe('user_789'); + expect(mocks.getSession).not.toHaveBeenCalled(); + }); + + test('applies native signed-out event payload without fetching the session', async () => { + mocks.getSession.mockResolvedValue({ + sessionId: 'sess_123', + user: { id: 'user_123' }, + }); + + const { result } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(result.current.sessionId).toBe('sess_123'); + }); + + mocks.getSession.mockClear(); + + act(() => { + notifyNativeSessionChanged({ + sessionId: null, + user: null, + }); + }); + + expect(result.current.isSignedIn).toBe(false); + expect(result.current.sessionId).toBeNull(); + expect(result.current.user).toBeNull(); + expect(mocks.getSession).not.toHaveBeenCalled(); + }); + + test('removes the native session listener on unmount', async () => { + mocks.getSession.mockResolvedValue(null); + + const { unmount } = renderHook(() => useNativeSession()); + + await waitFor(() => { + expect(mocks.getSession).toHaveBeenCalledTimes(1); + }); + + unmount(); + + act(() => { + notifyNativeSessionChanged(); + }); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mocks.getSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/expo/src/hooks/nativeSessionEvents.ts b/packages/expo/src/hooks/nativeSessionEvents.ts new file mode 100644 index 00000000000..c79daa2217c --- /dev/null +++ b/packages/expo/src/hooks/nativeSessionEvents.ts @@ -0,0 +1,29 @@ +export interface NativeSessionSnapshot { + sessionId?: string | null; + session?: { id?: string | null } | null; + user?: { + id: string; + firstName?: string | null; + lastName?: string | null; + imageUrl?: string | null; + primaryEmailAddress?: string | null; + primaryPhoneNumber?: string | null; + } | null; + clientToken?: string | null; +} + +type NativeSessionListener = (snapshot?: NativeSessionSnapshot) => void; + +const listeners = new Set(); + +export function addNativeSessionListener(listener: NativeSessionListener): () => void { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +} + +export function notifyNativeSessionChanged(snapshot?: NativeSessionSnapshot): void { + listeners.forEach(listener => listener(snapshot)); +} diff --git a/packages/expo/src/hooks/useNativeClientEvents.ts b/packages/expo/src/hooks/useNativeClientEvents.ts index 03b4fcfd3e8..c60b234164b 100644 --- a/packages/expo/src/hooks/useNativeClientEvents.ts +++ b/packages/expo/src/hooks/useNativeClientEvents.ts @@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'; import { NativeEventEmitter, Platform } from 'react-native'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; +import { notifyNativeSessionChanged, type NativeSessionSnapshot } from './nativeSessionEvents'; /** * Local marker for a native client event. */ -interface NativeClientEvent { +export interface NativeClientEvent extends NativeSessionSnapshot { issuedAt: number; } @@ -19,7 +20,10 @@ type RefreshClientEventSubscription = { }; type RefreshClientEventEmitter = { - addListener: (eventName: 'refreshClient', listener: () => void) => RefreshClientEventSubscription; + addListener: ( + eventName: 'refreshClient', + listener: (snapshot?: NativeSessionSnapshot) => void, + ) => RefreshClientEventSubscription; }; /** @@ -41,8 +45,9 @@ export function useNativeClientEvents(): UseNativeClientEventsReturn { ? (ClerkExpo as RefreshClientEventEmitter) : (new NativeEventEmitter(ClerkExpo) as RefreshClientEventEmitter); - subscription = eventEmitter.addListener('refreshClient', () => { - setNativeClientEvent({ issuedAt: Date.now() }); + subscription = eventEmitter.addListener('refreshClient', snapshot => { + notifyNativeSessionChanged(snapshot); + setNativeClientEvent({ issuedAt: Date.now(), ...snapshot }); }); } catch (error) { if (__DEV__) { diff --git a/packages/expo/src/hooks/useNativeSession.ts b/packages/expo/src/hooks/useNativeSession.ts index a05a4a8c341..add1826906a 100644 --- a/packages/expo/src/hooks/useNativeSession.ts +++ b/packages/expo/src/hooks/useNativeSession.ts @@ -1,17 +1,14 @@ import { useCallback, useEffect, useState } from 'react'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; +import { addNativeSessionListener, type NativeSessionSnapshot } from './nativeSessionEvents'; + +type NativeSessionUser = NonNullable; // Native session data structure (normalized) interface NativeSessionData { sessionId?: string; - user?: { - id: string; - firstName?: string; - lastName?: string; - imageUrl?: string; - primaryEmailAddress?: string; - }; + user?: NativeSessionUser; } // Raw result from the native module (may vary by platform) @@ -88,6 +85,13 @@ export function useNativeSession(): UseNativeSessionReturn { const [sessionId, setSessionId] = useState(null); const [user, setUser] = useState(null); + const applySnapshot = useCallback((snapshot: NativeSessionSnapshot | NativeSessionRawResult | null) => { + const id = snapshot?.sessionId ?? snapshot?.session?.id ?? null; + setSessionId(id); + setUser(snapshot?.user ?? null); + setIsLoading(false); + }, []); + const refresh = useCallback(async () => { if (!isNativeSupported || !ClerkExpo?.getSession) { setIsLoading(false); @@ -97,10 +101,7 @@ export function useNativeSession(): UseNativeSessionReturn { try { setIsLoading(true); const result = (await ClerkExpo.getSession()) as NativeSessionRawResult | null; - // Normalize: iOS returns { sessionId }, Android returns { session: { id } } - const id = result?.sessionId ?? result?.session?.id ?? null; - setSessionId(id); - setUser(result?.user ?? null); + applySnapshot(result); } catch (error) { if (__DEV__) { console.error('[useNativeSession] Error fetching native session:', error); @@ -110,13 +111,24 @@ export function useNativeSession(): UseNativeSessionReturn { } finally { setIsLoading(false); } - }, []); + }, [applySnapshot]); // Check native session on mount useEffect(() => { - refresh(); + void refresh(); }, [refresh]); + useEffect(() => { + return addNativeSessionListener(snapshot => { + if (snapshot) { + applySnapshot(snapshot); + return; + } + + void refresh(); + }); + }, [applySnapshot, refresh]); + return { isAvailable: isNativeSupported && !!ClerkExpo, isLoading, diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index f7d0f158614..96977f0693f 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -1,15 +1,17 @@ import '../polyfills'; import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; +import { useAuth } from '@clerk/react'; import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; -import { type MutableRefObject, useEffect, useRef } from 'react'; +import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'; import { Platform } from 'react-native'; import type { TokenCache } from '../cache/types'; import { CLERK_CLIENT_JWT_KEY } from '../constants'; -import { useNativeClientEvents } from '../hooks/useNativeClientEvents'; -import NativeClerkModule from '../specs/NativeClerkModule'; +import { notifyNativeSessionChanged, type NativeSessionSnapshot } from '../hooks/nativeSessionEvents'; +import { type NativeClientEvent, useNativeClientEvents } from '../hooks/useNativeClientEvents'; import { tokenCache as defaultTokenCache } from '../token-cache'; +import { ClerkExpoModule as NativeClerkModule } from '../utils/native-module'; import { isNative, isWeb } from '../utils/runtime'; import { maybeCompleteAuthSession } from './maybeCompleteAuthSession'; import { getClerkInstance } from './singleton'; @@ -53,34 +55,88 @@ const SDK_METADATA = { version: PACKAGE_VERSION, }; +const tokenCacheReadTimeoutMs = 1_000; +const clientTokenPollIntervalMs = 100; +const clientTokenAvailabilityTimeoutMs = 3_000; + type SyncableClerkInstance = { addListener?: (listener: (payload?: unknown) => void, options?: { skipInitialEmit?: boolean }) => () => void; addOnLoaded?: (listener: () => void) => void; client?: { lastActiveSessionId?: string | null } | null; loaded?: boolean; session?: { id?: string | null } | null; - setActive?: (params: { session: string }) => void | Promise; + setActive?: (params: { session: string | null }) => void | Promise; __internal_reloadInitialResources?: () => void | Promise; }; -async function waitForNativeClientToken(): Promise { +type NativeSessionResult = { + sessionId?: string | null; + session?: { id?: string | null } | null; +} | null; + +type NativeRefreshFromJsOptions = { + signOutNative?: boolean; + signOutSessionId?: string | null; + waitForToken: boolean; +}; + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function readNativeSessionId(): Promise { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo?.getSession) { + return undefined; + } + + const nativeSession = (await ClerkExpo.getSession()) as NativeSessionResult; + return nativeSession?.sessionId ?? nativeSession?.session?.id ?? null; +} + +function getNativeSessionIdFromSnapshot(snapshot: NativeSessionSnapshot | null | undefined): string | null | undefined { + if (!snapshot || (!('sessionId' in snapshot) && !('session' in snapshot))) { + return undefined; + } + + return snapshot.sessionId ?? snapshot.session?.id ?? null; +} + +function getNativeClientTokenFromSnapshot( + snapshot: NativeSessionSnapshot | null | undefined, +): string | null | undefined { + if (!snapshot || !('clientToken' in snapshot)) { + return undefined; + } + + return snapshot.clientToken ?? null; +} + +async function readNativeClientToken({ waitForToken }: { waitForToken: boolean }): Promise { const ClerkExpo = NativeClerkModule; if (!ClerkExpo?.getClientToken) { return null; } - const maxAttempts = 30; - const intervalMs = 100; + const startedAt = Date.now(); - for (let attempt = 0; attempt < maxAttempts; attempt++) { + do { const nativeClientToken = await ClerkExpo.getClientToken(); if (nativeClientToken) { return nativeClientToken; } - await new Promise(resolve => setTimeout(resolve, intervalMs)); - } - return null; + if (!waitForToken) { + return null; + } + + const remainingMs = clientTokenAvailabilityTimeoutMs - (Date.now() - startedAt); + if (remainingMs <= 0) { + return null; + } + + await delay(Math.min(clientTokenPollIntervalMs, remainingMs)); + } while (true); } async function syncClientTokenToCache(tokenCache: TokenCache | undefined, clientToken: string | null): Promise { @@ -91,30 +147,126 @@ async function syncClientTokenToCache(tokenCache: TokenCache | undefined, client } } +function hasActiveJsSession(clerkInstance: SyncableClerkInstance): boolean { + return Boolean(clerkInstance.session?.id || clerkInstance.client?.lastActiveSessionId); +} + +async function getCachedClientToken(tokenCache: TokenCache | undefined): Promise { + if (!tokenCache) { + return null; + } + + let timeoutId: ReturnType | undefined; + try { + return ( + (await Promise.race([ + tokenCache.getToken(CLERK_CLIENT_JWT_KEY), + new Promise(resolve => { + timeoutId = setTimeout(() => resolve(null), tokenCacheReadTimeoutMs); + }), + ])) ?? null + ); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} + +async function readCachedClientToken({ + tokenCache, + waitForToken, +}: { + tokenCache: TokenCache | undefined; + waitForToken: boolean; +}): Promise { + const startedAt = Date.now(); + + do { + const token = await getCachedClientToken(tokenCache); + if (token || !waitForToken) { + return token; + } + + const remainingMs = clientTokenAvailabilityTimeoutMs - (Date.now() - startedAt); + if (remainingMs <= 0) { + return null; + } + + await delay(Math.min(clientTokenPollIntervalMs, remainingMs)); + } while (true); +} + async function syncNativeClientToJs({ clerkInstance, + clearMissingNativeToken, + nativeClientEvent, + skipNextJsAuthSyncRef, tokenCache, }: { clerkInstance: SyncableClerkInstance; + clearMissingNativeToken: boolean; + nativeClientEvent?: NativeClientEvent | null; + skipNextJsAuthSyncRef?: MutableRefObject; tokenCache: TokenCache | undefined; }): Promise { - const nativeClientToken = await waitForNativeClientToken(); + const nativeSessionIdFromEvent = clearMissingNativeToken + ? getNativeSessionIdFromSnapshot(nativeClientEvent) + : undefined; + const nativeSessionId = + nativeSessionIdFromEvent !== undefined + ? nativeSessionIdFromEvent + : clearMissingNativeToken + ? await readNativeSessionId() + : undefined; + let clearedJsSession = false; + + if (nativeSessionId === null && clerkInstance.session?.id && typeof clerkInstance.setActive === 'function') { + if (skipNextJsAuthSyncRef) { + skipNextJsAuthSyncRef.current = true; + } + await clerkInstance.setActive({ session: null }); + clearedJsSession = true; + } + + const nativeClientTokenFromEvent = clearMissingNativeToken + ? getNativeClientTokenFromSnapshot(nativeClientEvent) + : undefined; + const nativeClientToken = + nativeClientTokenFromEvent !== undefined + ? nativeClientTokenFromEvent + : await readNativeClientToken({ + waitForToken: !clearMissingNativeToken || !hasActiveJsSession(clerkInstance), + }); const effectiveTokenCache = tokenCache ?? defaultTokenCache; + if (!nativeClientToken && !clearMissingNativeToken) { + return; + } + await syncClientTokenToCache(effectiveTokenCache, nativeClientToken); if (typeof clerkInstance.__internal_reloadInitialResources === 'function') { await clerkInstance.__internal_reloadInitialResources(); } - const nativeActiveSessionId = clerkInstance.client?.lastActiveSessionId; - const jsActiveSessionId = clerkInstance.session?.id; + const nativeActiveSessionId = + nativeSessionId !== undefined ? nativeSessionId : clerkInstance.client?.lastActiveSessionId; + const jsActiveSessionId = clearedJsSession ? null : clerkInstance.session?.id; if ( nativeActiveSessionId && nativeActiveSessionId !== jsActiveSessionId && typeof clerkInstance.setActive === 'function' ) { + if (skipNextJsAuthSyncRef) { + skipNextJsAuthSyncRef.current = true; + } await clerkInstance.setActive({ session: nativeActiveSessionId }); + } else if (!nativeActiveSessionId && jsActiveSessionId && typeof clerkInstance.setActive === 'function') { + if (skipNextJsAuthSyncRef) { + skipNextJsAuthSyncRef.current = true; + } + await clerkInstance.setActive({ session: null }); } } @@ -129,59 +281,153 @@ function NativeClientSync({ clerkInstance, isSyncingNativeClientToJsRef, publishableKey, + skipNextJsAuthSyncRef, tokenCache, }: { clerkInstance: SyncableClerkInstance | null | undefined; isSyncingNativeClientToJsRef: MutableRefObject; publishableKey: string; + skipNextJsAuthSyncRef: MutableRefObject; tokenCache: TokenCache | undefined; }): null { + const { isLoaded, sessionId } = useAuth(); const isRefreshingNativeFromJsRef = useRef(false); + const hasSeenLoadedJsAuthRef = useRef(isLoaded); + const previousJsSessionIdRef = useRef(isLoaded ? (sessionId ?? null) : null); + const pendingNativeRefreshRef = useRef(null); + const nativeRefreshGenerationRef = useRef(0); // Use the provided tokenCache, falling back to the default SecureStore cache const effectiveTokenCache = tokenCache ?? defaultTokenCache; - useEffect(() => { - if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { - return; - } + const queueNativeRefreshFromJs = useCallback( + (options: NativeRefreshFromJsOptions): void => { + if (isRefreshingNativeFromJsRef.current && !options.signOutNative) { + pendingNativeRefreshRef.current = options; + return; + } - return clerkInstance.addListener( - () => { - if (isSyncingNativeClientToJsRef.current || isRefreshingNativeFromJsRef.current) { + const initialGeneration = nativeRefreshGenerationRef.current + 1; + nativeRefreshGenerationRef.current = initialGeneration; + if (options.signOutNative) { + pendingNativeRefreshRef.current = null; + } + isRefreshingNativeFromJsRef.current = true; + + const refreshNativeFromJsClient = async ( + { signOutNative, signOutSessionId, waitForToken }: NativeRefreshFromJsOptions, + generation: number, + ): Promise => { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo || generation !== nativeRefreshGenerationRef.current) { return; } - isRefreshingNativeFromJsRef.current = true; + if (signOutNative) { + await ClerkExpo.signOut?.(signOutSessionId ?? null); + return; + } - const refreshNativeFromJsClient = async (): Promise => { - const ClerkExpo = NativeClerkModule; - if (!ClerkExpo) { + const bearerToken = await readCachedClientToken({ + tokenCache: effectiveTokenCache, + waitForToken, + }); + if (generation !== nativeRefreshGenerationRef.current) { + return; + } + + if (bearerToken) { + // configure writes the token and refreshes native client state. + await ClerkExpo.configure(publishableKey, bearerToken); + } else { + const nativeClientToken = (await ClerkExpo.getClientToken?.()) ?? null; + if (generation !== nativeRefreshGenerationRef.current) { return; } - const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; - if (bearerToken) { - // configure writes the token and refreshes native client state. - await ClerkExpo.configure(publishableKey, bearerToken); - } else { - // No token to push; ask native to reload its current client. + if (nativeClientToken) { + // No JS token to push, but native has a stored client token to reload. await ClerkExpo.refreshClient(); } - }; + } + }; - void refreshNativeFromJsClient() - .catch((error: unknown) => { - if (__DEV__) { - console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); - } - }) - .finally(() => { + let latestRunGeneration = initialGeneration; + + void (async () => { + let pendingOptions = options; + let generation = initialGeneration; + do { + latestRunGeneration = generation; + pendingNativeRefreshRef.current = null; + await refreshNativeFromJsClient(pendingOptions, generation); + pendingOptions = pendingNativeRefreshRef.current ?? { waitForToken: false }; + if (pendingNativeRefreshRef.current !== null) { + generation = nativeRefreshGenerationRef.current + 1; + nativeRefreshGenerationRef.current = generation; + } + } while (pendingNativeRefreshRef.current !== null); + })() + .catch((error: unknown) => { + if (__DEV__) { + console.warn('[NativeClientSync] Failed to refresh native client from JS client change:', error); + } + }) + .finally(() => { + if (latestRunGeneration === nativeRefreshGenerationRef.current) { isRefreshingNativeFromJsRef.current = false; - }); + } + }); + }, + [effectiveTokenCache, isSyncingNativeClientToJsRef, publishableKey], + ); + + useEffect(() => { + if (!isLoaded) { + return; + } + + const previousSessionId = previousJsSessionIdRef.current; + const hasSeenLoadedJsAuth = hasSeenLoadedJsAuthRef.current; + hasSeenLoadedJsAuthRef.current = true; + previousJsSessionIdRef.current = sessionId ?? null; + + if (!hasSeenLoadedJsAuth) { + if (sessionId) { + queueNativeRefreshFromJs({ waitForToken: true }); + } + return; + } + + if (skipNextJsAuthSyncRef.current) { + skipNextJsAuthSyncRef.current = false; + return; + } + + if (sessionId) { + queueNativeRefreshFromJs({ waitForToken: true }); + } else if (previousSessionId) { + queueNativeRefreshFromJs({ signOutNative: true, signOutSessionId: previousSessionId, waitForToken: false }); + } + }, [isLoaded, queueNativeRefreshFromJs, sessionId, skipNextJsAuthSyncRef]); + + useEffect(() => { + if (!clerkInstance || typeof clerkInstance.addListener !== 'function') { + return; + } + + return clerkInstance.addListener( + () => { + if (isSyncingNativeClientToJsRef.current) { + return; + } + + if (hasActiveJsSession(clerkInstance)) { + queueNativeRefreshFromJs({ waitForToken: true }); + } }, { skipInitialEmit: true }, ); - }, [clerkInstance, effectiveTokenCache, isSyncingNativeClientToJsRef, publishableKey]); + }, [clerkInstance, queueNativeRefreshFromJs]); return null; } @@ -217,20 +463,30 @@ function useNativeSessionBootstrap({ const ClerkExpo = NativeClerkModule; if (ClerkExpo?.configure) { + await ClerkExpo.configure(publishableKey, null); + + if (!isMountedRef.current) { + return; + } + notifyNativeSessionChanged(); + const effectiveTokenCache = tokenCache ?? defaultTokenCache; let bearerToken: string | null = null; try { - bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + bearerToken = await getCachedClientToken(effectiveTokenCache); } catch (e) { if (__DEV__) { console.warn('[ClerkProvider] Token cache read failed:', e); } } - await ClerkExpo.configure(publishableKey, bearerToken); + if (bearerToken) { + await ClerkExpo.configure(publishableKey, bearerToken); - if (!isMountedRef.current) { - return; + if (!isMountedRef.current) { + return; + } + notifyNativeSessionChanged(); } if (clerkInstance) { @@ -261,6 +517,7 @@ function useNativeSessionBootstrap({ try { await syncNativeClientToJs({ clerkInstance, + clearMissingNativeToken: false, tokenCache, }); } finally { @@ -323,6 +580,7 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps(props: ClerkProviderProps(props: ClerkProviderProps )} diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx new file mode 100644 index 00000000000..faebfdc6549 --- /dev/null +++ b/packages/expo/src/provider/__tests__/ClerkProvider.nativeSession.test.tsx @@ -0,0 +1,454 @@ +import { render, waitFor } from '@testing-library/react'; +import React, { type ReactNode } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { CLERK_CLIENT_JWT_KEY } from '../../constants'; +import { ClerkProvider } from '../ClerkProvider'; + +const mocks = vi.hoisted(() => { + return { + configure: vi.fn(), + getSession: vi.fn(), + getClientToken: vi.fn(), + nativeClientEvent: null as unknown, + notifyNativeSessionChanged: vi.fn(), + refreshClient: vi.fn(), + signOut: vi.fn(), + tokenCache: { + clearToken: vi.fn(), + getToken: vi.fn(), + saveToken: vi.fn(), + }, + clerkInstance: { + __internal_reloadInitialResources: vi.fn(), + addListener: vi.fn(), + client: { + lastActiveSessionId: 'sess_native', + }, + loaded: true, + session: { + id: null, + }, + setActive: vi.fn(), + }, + authState: { + isLoaded: true, + sessionId: null as string | null, + }, + clerkListener: undefined as (() => void) | undefined, + }; +}); + +vi.mock('../../polyfills', () => ({})); + +vi.mock('@clerk/react', () => { + return { + useAuth: () => mocks.authState, + }; +}); + +vi.mock('@clerk/react/internal', () => { + return { + InternalClerkProvider: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + }; +}); + +vi.mock('react-native', () => { + return { + NativeModules: { + BlobModule: {}, + }, + Platform: { + OS: 'ios', + constants: { + reactNativeVersion: { + major: 0, + minor: 81, + patch: 0, + }, + }, + }, + }; +}); + +vi.mock('expo-secure-store', () => { + return { + AFTER_FIRST_UNLOCK: 0, + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + }; +}); + +vi.mock('../../hooks/nativeSessionEvents', () => { + return { + notifyNativeSessionChanged: mocks.notifyNativeSessionChanged, + }; +}); + +vi.mock('../../hooks/useNativeClientEvents', () => { + return { + useNativeClientEvents: () => ({ + nativeClientEvent: mocks.nativeClientEvent, + }), + }; +}); + +vi.mock('../../specs/NativeClerkModule', () => { + return { + default: { + configure: mocks.configure, + getSession: mocks.getSession, + getClientToken: mocks.getClientToken, + refreshClient: mocks.refreshClient, + signOut: mocks.signOut, + }, + }; +}); + +vi.mock('../../utils/runtime', () => { + return { + isNative: () => true, + isWeb: () => false, + }; +}); + +vi.mock('../singleton', () => { + return { + getClerkInstance: () => mocks.clerkInstance, + }; +}); + +describe('ClerkProvider native session notifications', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.nativeClientEvent = null; + mocks.configure.mockResolvedValue(undefined); + mocks.getSession.mockResolvedValue({ sessionId: 'sess_native' }); + mocks.getClientToken.mockResolvedValue('native-client-token'); + mocks.signOut.mockResolvedValue(undefined); + mocks.tokenCache.getToken.mockResolvedValue('client-token'); + mocks.tokenCache.saveToken.mockResolvedValue(undefined); + mocks.tokenCache.clearToken.mockResolvedValue(undefined); + mocks.clerkListener = undefined; + mocks.clerkInstance.addListener.mockImplementation(listener => { + mocks.clerkListener = listener; + return vi.fn(); + }); + mocks.clerkInstance.client.lastActiveSessionId = 'sess_native'; + mocks.clerkInstance.session.id = null; + mocks.authState.isLoaded = true; + mocks.authState.sessionId = null; + }); + + test('refreshes useNativeSession subscribers after initial native configure', async () => { + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); + }); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + }); + + test('activates the JS session after native signs in', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); + mocks.clerkInstance.client.lastActiveSessionId = null; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); + + mocks.getClientToken.mockResolvedValue('native-client-token'); + mocks.clerkInstance.client.lastActiveSessionId = 'sess_native'; + mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); + mocks.clerkInstance.setActive.mockClear(); + mocks.getClientToken.mockClear(); + mocks.getSession.mockClear(); + mocks.notifyNativeSessionChanged.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + sessionId: 'sess_native', + clientToken: 'native-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); + }); + expect(mocks.getClientToken).not.toHaveBeenCalled(); + expect(mocks.getSession).not.toHaveBeenCalled(); + expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); + expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: 'sess_native' }); + expect(mocks.notifyNativeSessionChanged).not.toHaveBeenCalled(); + + mocks.configure.mockClear(); + mocks.authState.sessionId = 'sess_native'; + rerender( + , + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mocks.configure).not.toHaveBeenCalled(); + }); + + test('clears the JS active session after native signs out', async () => { + mocks.clerkInstance.client.lastActiveSessionId = 'sess_js'; + mocks.clerkInstance.session.id = 'sess_js'; + mocks.authState.sessionId = 'sess_js'; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + expect(mocks.clerkInstance.setActive).not.toHaveBeenCalled(); + + mocks.getClientToken.mockResolvedValue(null); + mocks.getSession.mockResolvedValue(null); + mocks.getClientToken.mockClear(); + mocks.getSession.mockClear(); + mocks.clerkInstance.client.lastActiveSessionId = null; + mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); + mocks.clerkInstance.setActive.mockClear(); + mocks.notifyNativeSessionChanged.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + sessionId: null, + clientToken: null, + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.tokenCache.clearToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY); + }); + expect(mocks.getClientToken).not.toHaveBeenCalled(); + expect(mocks.getSession).not.toHaveBeenCalled(); + expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); + expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: null }); + expect(mocks.notifyNativeSessionChanged).not.toHaveBeenCalled(); + + mocks.signOut.mockClear(); + mocks.authState.sessionId = null; + rerender( + , + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mocks.signOut).not.toHaveBeenCalled(); + }); + + test('does not refresh native from JS while applying a native event', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); + mocks.clerkInstance.client.lastActiveSessionId = null; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + + mocks.configure.mockClear(); + mocks.clerkInstance.setActive.mockImplementation(async () => { + mocks.clerkListener?.(); + }); + mocks.clerkInstance.client.lastActiveSessionId = 'sess_native'; + mocks.nativeClientEvent = { + issuedAt: 1, + sessionId: 'sess_native', + clientToken: 'native-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: 'sess_native' }); + }); + + expect(mocks.configure).not.toHaveBeenCalled(); + }); + + test('clears the JS active session before waiting for initial resources after native sign-out', async () => { + mocks.clerkInstance.client.lastActiveSessionId = 'sess_js'; + mocks.clerkInstance.session.id = 'sess_js'; + mocks.authState.sessionId = 'sess_js'; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.notifyNativeSessionChanged).toHaveBeenCalled(); + }); + + mocks.getClientToken.mockResolvedValue('native-client-token'); + mocks.getSession.mockResolvedValue(null); + mocks.getClientToken.mockClear(); + mocks.getSession.mockClear(); + mocks.clerkInstance.__internal_reloadInitialResources.mockImplementation(() => new Promise(() => {})); + mocks.clerkInstance.setActive.mockClear(); + mocks.notifyNativeSessionChanged.mockClear(); + + mocks.nativeClientEvent = { + issuedAt: 1, + sessionId: null, + clientToken: 'native-client-token', + }; + rerender( + , + ); + + await waitFor(() => { + expect(mocks.clerkInstance.setActive).toHaveBeenCalledWith({ session: null }); + }); + expect(mocks.getSession).not.toHaveBeenCalled(); + }); + + test('does not refresh native from JS when neither side has a client token', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); + mocks.clerkInstance.client.lastActiveSessionId = null; + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + await waitFor(() => { + expect(mocks.tokenCache.getToken).toHaveBeenCalled(); + }); + + expect(mocks.refreshClient).not.toHaveBeenCalled(); + }); + + test('pushes the cached JS client token to native after JS sign-in', async () => { + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.getClientToken.mockResolvedValue(null); + mocks.clerkInstance.client.lastActiveSessionId = null; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + }); + + mocks.configure.mockClear(); + mocks.tokenCache.getToken.mockResolvedValue('client-token'); + mocks.authState.sessionId = 'sess_js'; + + rerender( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); + }); + }); + + test('refreshes native after JS signs out', async () => { + mocks.authState.sessionId = 'sess_js'; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); + }); + + mocks.configure.mockClear(); + mocks.notifyNativeSessionChanged.mockClear(); + mocks.refreshClient.mockClear(); + mocks.signOut.mockClear(); + mocks.tokenCache.getToken.mockResolvedValue(null); + mocks.authState.sessionId = null; + + rerender( + , + ); + + await waitFor(() => { + expect(mocks.signOut).toHaveBeenCalledWith('sess_js'); + }); + expect(mocks.configure).not.toHaveBeenCalled(); + expect(mocks.refreshClient).not.toHaveBeenCalled(); + expect(mocks.notifyNativeSessionChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index 1ffb278809a..4f8c566e2c7 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -10,6 +10,7 @@ interface Spec { getSession(): Promise; getClientToken(): Promise; refreshClient(): Promise; + signOut(sessionId: string | null): Promise; removeListeners?(count: number): void; } diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index 9600dea3835..1e560c53544 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -10,6 +10,7 @@ export interface Spec extends TurboModule { getSession(): Promise; getClientToken(): Promise; refreshClient(): Promise; + signOut(sessionId: string | null): Promise; // Required by NativeEventEmitter for internal native client refresh events. // This is not part of the public @clerk/expo API. removeListeners(count: number): void; diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index cdab7fb0e7e..839fa93d02e 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -8,12 +8,33 @@ function loadNativeModule(): typeof NativeClerkModule | null { if (!isNativeSupported) { return null; } + let nativeModule: typeof NativeClerkModule | null = null; + try { - return NativeClerkModule; + nativeModule = NativeClerkModule; } catch (e) { if (__DEV__) { console.warn('[ClerkExpo] Native module not available:', e); } + } + + if (nativeModule?.configure) { + return nativeModule; + } + + try { + // Expo SDK 54 can expose installed modules through Expo's module registry even + // when the generated TurboModule object is incomplete. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { requireNativeModule } = require('expo'); + return requireNativeModule('ClerkExpo') ?? nativeModule; + } catch (e) { + if (__DEV__ && !nativeModule) { + console.warn('[ClerkExpo] Native module not available:', e); + } + if (nativeModule) { + return nativeModule; + } return null; } }