Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Boolean>) 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<Boolean>).value = false
Clerk.reinitialize()
return
}
}

debugLog(TAG, "forceClientRefresh - _isInitialized flow not found")
} catch (e: Exception) {
debugLog(TAG, "forceClientRefresh failed: ${e.message}")
}
}

// MARK: - presentAuth

@ReactMethod
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}")
Expand All @@ -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
}

Expand Down Expand Up @@ -258,17 +326,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
}

private fun handleAuthResult(resultCode: Int, data: Intent?) {
debugLog(TAG, "handleAuthResult - resultCode: $resultCode")

val promise = pendingAuthPromise ?: return
pendingAuthPromise = null

if (resultCode == Activity.RESULT_OK) {
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)
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 0 additions & 31 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
97 changes: 80 additions & 17 deletions packages/expo/ios/ClerkViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand All @@ -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).
Expand Down Expand Up @@ -106,35 +126,78 @@ 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,
kSecAttrAccount as String: nativeTokenKey,
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(
Expand Down
Loading
Loading