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 f08753c21fe..e79cc125cb8 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 @@ -14,6 +14,7 @@ import com.facebook.react.bridge.WritableNativeMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout @@ -74,9 +75,27 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : .edit() .putString("DEVICE_TOKEN", bearerToken) .apply() - debugLog(TAG, "configure - wrote JS bearer token to native SharedPreferences") } + if (Clerk.isInitialized.value) { + // Already initialized — force a client refresh so the SDK + // picks up the new device token from SharedPreferences. + forceClientRefresh() + + // Wait for session to appear with the new token (up to 5s) + try { + withTimeout(5_000L) { + Clerk.sessionFlow.first { it != null } + } + } catch (_: TimeoutCancellationException) { + debugLog(TAG, "configure - session did not appear after force refresh") + } + + promise.resolve(null) + return@launch + } + + // First-time initialization Clerk.initialize(reactApplicationContext, pubKey) // Wait for initialization to complete with timeout @@ -108,6 +127,55 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } } + /** + * Forces the Clerk SDK to re-fetch client/environment data from the API. + * + * This is needed when a new device token has been written to SharedPreferences + * but the SDK was already initialized (so Clerk.initialize() is a no-op). + * + * Uses reflection to find the ConfigurationManager instance by type (field name + * may vary across SDK versions), then sets _isInitialized to false so + * reinitialize() proceeds with a fresh client/environment fetch. + */ + private fun forceClientRefresh() { + try { + // Find the ConfigurationManager field by type since the name may differ + val clerkClass = Clerk::class.java + var configManager: Any? = null + + for (field in clerkClass.declaredFields) { + field.isAccessible = true + val fieldValue = field.get(Clerk) + if (fieldValue != null && fieldValue.javaClass.name.contains("ConfigurationManager")) { + configManager = fieldValue + break + } + } + + if (configManager == null) { + debugLog(TAG, "forceClientRefresh - ConfigurationManager not found") + return + } + + // Find _isInitialized field (MutableStateFlow) in ConfigurationManager + // and set it to false so reinitialize() will proceed + for (field in configManager.javaClass.declaredFields) { + field.isAccessible = true + val fieldValue = field.get(configManager) + if (fieldValue is MutableStateFlow<*> && fieldValue.value is Boolean && fieldValue.value == true) { + @Suppress("UNCHECKED_CAST") + (fieldValue as MutableStateFlow).value = false + Clerk.reinitialize() + return + } + } + + debugLog(TAG, "forceClientRefresh - _isInitialized flow not found") + } catch (e: Exception) { + debugLog(TAG, "forceClientRefresh failed: ${e.message}") + } + } + // MARK: - presentAuth @ReactMethod @@ -174,15 +242,15 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun getSession(promise: Promise) { if (!Clerk.isInitialized.value) { - promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") + // Return null when not initialized (matches iOS behavior) + // so callers can proceed to call configure() with a bearer token. + promise.resolve(null) return } val session = Clerk.session val user = Clerk.user - debugLog(TAG, "getSession - hasSession: ${session != null}, hasUser: ${user != null}") - val result = WritableNativeMap() session?.let { @@ -217,7 +285,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : try { val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) val deviceToken = prefs.getString("DEVICE_TOKEN", null) - debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found" else "null"}") promise.resolve(deviceToken) } catch (e: Exception) { debugLog(TAG, "getClientToken failed: ${e.message}") @@ -230,7 +297,8 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : @ReactMethod override fun signOut(promise: Promise) { if (!Clerk.isInitialized.value) { - promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") + // Resolve gracefully when not initialized (matches iOS behavior) + promise.resolve(null) return } @@ -258,8 +326,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } private fun handleAuthResult(resultCode: Int, data: Intent?) { - debugLog(TAG, "handleAuthResult - resultCode: $resultCode") - val promise = pendingAuthPromise ?: return pendingAuthPromise = null @@ -267,8 +333,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : val session = Clerk.session val user = Clerk.user - debugLog(TAG, "handleAuthResult - hasSession: ${session != null}, hasUser: ${user != null}") - val result = WritableNativeMap() // Top-level sessionId for JS SDK compatibility (matches iOS response format) @@ -296,7 +360,6 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : promise.resolve(result) } else { - debugLog(TAG, "handleAuthResult - user cancelled") val result = WritableNativeMap() result.putBoolean("cancelled", true) promise.resolve(result) diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index eabfb44d685..6099b665a04 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -170,37 +170,6 @@ class ClerkExpoModule: RCTEventEmitter { } } - // MARK: - getClientToken - - @objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - // Use a custom keychain service if configured in Info.plist (for extension apps - // sharing a keychain group). Falls back to the main bundle identifier. - let keychainService: String = { - if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty { - return custom - } - return Bundle.main.bundleIdentifier ?? "" - }() - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: "clerkDeviceToken", - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - if status == errSecSuccess, let data = result as? Data { - resolve(String(data: data, encoding: .utf8)) - } else { - resolve(nil) - } - } - // MARK: - signOut @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock, diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index d4a80aa6bc6..2e8428221d6 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -16,6 +16,7 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { private static let clerkLoadMaxAttempts = 30 private static let clerkLoadIntervalNs: UInt64 = 100_000_000 + private static var clerkConfigured = false private init() {} @@ -39,11 +40,30 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { // 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 { - Self.writeNativeDeviceTokenIfNeeded(token) + let existingToken = Self.readNativeDeviceToken() + Self.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 { + Self.clearCachedClerkData() + } } else { Self.syncJSTokenToNativeKeychainIfNeeded() } + // 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 a no-op on subsequent calls, so we use refreshClient(). + if Self.clerkConfigured, let token = bearerToken, !token.isEmpty { + _ = try? await Clerk.shared.refreshClient() + return + } + + Self.clerkConfigured = true Clerk.configure(publishableKey: publishableKey) // Wait for Clerk to finish loading (cached data + API refresh). @@ -106,14 +126,48 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { SecItemAdd(writeQuery as CFDictionary, nil) } - /// Writes the provided bearer token as the native SDK's device token, - /// but only if the native SDK doesn't already have one. - private static func writeNativeDeviceTokenIfNeeded(_ token: String) { + /// Reads the native device token from keychain, if present. + private static func readNativeDeviceToken() -> String? { + guard let service = keychainService, !service.isEmpty else { return nil } + + var result: CFTypeRef? + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "clerkDeviceToken", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + /// 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() { + guard let service = keychainService, !service.isEmpty else { return } + + for key in ["cachedClient", "cachedEnvironment"] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } + } + + /// Writes the provided bearer token as the native SDK's device token. + /// If the native SDK already has a device token, it is updated with the new value. + private static func writeNativeDeviceToken(_ token: String) { guard let service = keychainService, !service.isEmpty else { return } let nativeTokenKey = "clerkDeviceToken" + guard let tokenData = token.data(using: .utf8) else { return } - // Check if native SDK already has a device token — don't overwrite + // Check if native SDK already has a device token let checkQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -121,20 +175,29 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol { kSecReturnData as String: false, kSecMatchLimit as String: kSecMatchLimitOne, ] + if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess { - return + // Update the existing token + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: nativeTokenKey, + ] + let updateAttributes: [String: Any] = [ + kSecValueData as String: tokenData, + ] + SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary) + } else { + // Write a new token + let writeQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: nativeTokenKey, + kSecValueData as String: tokenData, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + SecItemAdd(writeQuery as CFDictionary, nil) } - - // Write the provided token as native device token - guard let tokenData = token.data(using: .utf8) else { return } - let writeQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - kSecValueData as String: tokenData, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - SecItemAdd(writeQuery as CFDictionary, nil) } public func createAuthViewController( diff --git a/packages/expo/src/hooks/useUserProfileModal.ts b/packages/expo/src/hooks/useUserProfileModal.ts index d97b8c35b28..da7c6f4d081 100644 --- a/packages/expo/src/hooks/useUserProfileModal.ts +++ b/packages/expo/src/hooks/useUserProfileModal.ts @@ -1,6 +1,8 @@ -import { useClerk } from '@clerk/react'; +import { useClerk, useUser } from '@clerk/react'; import { useCallback, useRef } from 'react'; +import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import { tokenCache } from '../token-cache'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; // Raw result from the native module (may vary by platform) @@ -53,6 +55,7 @@ export interface UseUserProfileModalReturn { */ export function useUserProfileModal(): UseUserProfileModalReturn { const clerk = useClerk(); + const { user } = useUser(); const presentingRef = useRef(false); const presentUserProfile = useCallback(async () => { @@ -66,17 +69,36 @@ export function useUserProfileModal(): UseUserProfileModalReturn { presentingRef.current = true; try { + let hadNativeSessionBefore = false; + + // If native doesn't have a session but JS does (e.g. user signed in via custom form), + // sync the JS SDK's bearer token to native and wait for it before presenting. + if (user && ClerkExpo?.getSession && ClerkExpo?.configure) { + const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; + hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); + + if (!hadNativeSessionBefore) { + const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + await ClerkExpo.configure(clerk.publishableKey, bearerToken); + + // Re-check if configure produced a session + const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; + hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); + } + } + } + await ClerkExpo.presentUserProfile({ dismissable: true, }); - // Check if native session still exists after modal closes - // If session is null, user signed out from the native UI + // Only sign out the JS SDK if native HAD a session before the modal + // and now it's gone (user signed out from within native UI). const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); - if (!hasNativeSession) { - // Clear native session explicitly (may already be cleared, but ensure it) + if (!hasNativeSession && hadNativeSessionBefore) { try { await ClerkExpo.signOut?.(); } catch (e) { @@ -85,7 +107,6 @@ export function useUserProfileModal(): UseUserProfileModalReturn { } } - // Sign out from JS SDK to update isSignedIn state if (clerk?.signOut) { try { await clerk.signOut(); @@ -97,15 +118,13 @@ export function useUserProfileModal(): UseUserProfileModalReturn { } } } catch (error) { - // Dismissal resolves successfully with { dismissed: true }, so reaching - // here means a real native error (E_NOT_INITIALIZED, E_CREATE_FAILED, E_NO_ROOT_VC). if (__DEV__) { console.error('[useUserProfileModal] presentUserProfile failed:', error); } } finally { presentingRef.current = false; } - }, [clerk]); + }, [clerk, user]); return { presentUserProfile, diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx index 045d3027080..4e0795970ff 100644 --- a/packages/expo/src/native/UserButton.tsx +++ b/packages/expo/src/native/UserButton.tsx @@ -2,6 +2,8 @@ import { useClerk, useUser } from '@clerk/react'; import { useEffect, useRef, useState } from 'react'; import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import { tokenCache } from '../token-cache'; import { ClerkExpoModule as ClerkExpo, isNativeSupported } from '../utils/native-module'; // Raw result from native module (may vary by platform) @@ -133,16 +135,40 @@ export function UserButton(_props: UserButtonProps) { presentingRef.current = true; try { + // Track whether native had a session before the modal, so we can distinguish + // "user signed out from within the modal" from "native never had a session". + let hadNativeSessionBefore = false; + + // If native doesn't have a session but JS does (e.g. user signed in via custom form), + // sync the JS SDK's bearer token to native and wait for it before presenting. + if (clerkUser && ClerkExpo?.getSession && ClerkExpo?.configure) { + const preCheck = (await ClerkExpo.getSession()) as NativeSessionResult | null; + hadNativeSessionBefore = !!(preCheck?.sessionId || preCheck?.session?.id); + + if (!hadNativeSessionBefore) { + const bearerToken = (await tokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + await ClerkExpo.configure(clerk.publishableKey, bearerToken); + + // Re-check if configure produced a session + const postConfigure = (await ClerkExpo.getSession()) as NativeSessionResult | null; + hadNativeSessionBefore = !!(postConfigure?.sessionId || postConfigure?.session?.id); + } + } + } + await ClerkExpo.presentUserProfile({ dismissable: true, }); - // Check if native session still exists after modal closes - // If session is null, user signed out from the native UI + // Check if native session still exists after modal closes. + // Only sign out the JS SDK if the native SDK HAD a session before the modal + // and now it's gone (meaning the user signed out from within the native UI). + // If native never had a session (e.g. force refresh didn't work), don't sign out JS. const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); - if (!hasNativeSession) { + if (!hasNativeSession && hadNativeSessionBefore) { // Clear local state immediately for instant UI feedback setNativeUser(null); @@ -161,25 +187,12 @@ export function UserButton(_props: UserButtonProps) { await clerk.signOut(); } catch (e) { if (__DEV__) { - console.warn('[UserButton] JS SDK signOut error, attempting reload:', e); - } - // Even if signOut throws, try to force reload to clear stale state - const clerkRecord = clerk as unknown as Record; - if (typeof clerkRecord.__internal_reloadInitialResources === 'function') { - try { - await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); - } catch (reloadErr) { - if (__DEV__) { - console.warn('[UserButton] Best-effort reload failed:', reloadErr); - } - } + console.warn('[UserButton] JS SDK signOut error:', e); } } } } } catch (error) { - // Dismissal resolves successfully with { dismissed: true }, so reaching - // here means a real native error (E_NOT_INITIALIZED, E_CREATE_FAILED, E_NO_ROOT_VC). if (__DEV__) { console.error('[UserButton] presentUserProfile failed:', error); } diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 9a693773544..b842485b1da 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -1,6 +1,7 @@ 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 { useEffect, useRef } from 'react'; import { Platform } from 'react-native'; @@ -52,6 +53,76 @@ const SDK_METADATA = { version: PACKAGE_VERSION, }; +/** + * Syncs JS SDK auth state to the native Clerk SDK. + * + * When a user authenticates via the JS SDK (custom sign-in forms, useSignIn, etc.) + * rather than through native ``, the native SDK doesn't know about the + * session. This component watches for JS auth state changes and pushes the bearer + * token to the native SDK so native components (UserButton, UserProfileView) work. + * + * Must be rendered inside `ClerkReactProvider` so `useAuth()` has access to context. + */ +function NativeSessionSync({ + publishableKey, + tokenCache, +}: { + publishableKey: string; + tokenCache: TokenCache | undefined; +}) { + const { isSignedIn } = useAuth(); + const hasSyncedRef = useRef(false); + // Use the provided tokenCache, falling back to the default SecureStore cache + const effectiveTokenCache = tokenCache ?? defaultTokenCache; + + useEffect(() => { + if (!isSignedIn) { + hasSyncedRef.current = false; + return; + } + + if (hasSyncedRef.current) { + return; + } + + const syncToNative = async () => { + try { + const ClerkExpo = NativeClerkModule; + if (!ClerkExpo?.configure || !ClerkExpo?.getSession) { + return; + } + + // Check if native already has a session (e.g. auth via AuthView or initial load) + const nativeSession = (await ClerkExpo.getSession()) as { + sessionId?: string; + session?: { id: string }; + } | null; + const hasNativeSession = !!(nativeSession?.sessionId || nativeSession?.session?.id); + + if (hasNativeSession) { + hasSyncedRef.current = true; + return; + } + + // Read the JS SDK's client JWT and push it to the native SDK + const bearerToken = (await effectiveTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + if (bearerToken) { + await ClerkExpo.configure(publishableKey, bearerToken); + hasSyncedRef.current = true; + } + } catch (error) { + if (__DEV__) { + console.warn('[NativeSessionSync] Failed to sync JS session to native:', error); + } + } + }; + + void syncToNative(); + }, [isSignedIn, publishableKey, effectiveTokenCache]); + + return null; +} + export function ClerkProvider(props: ClerkProviderProps): JSX.Element { const { children, @@ -100,15 +171,25 @@ export function ClerkProvider(props: ClerkProviderProps(props: ClerkProviderProps + {isNative() && ( + + )} {children} );