Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9168040
feat(expo): add two-way JS/native session sync for expo native compon…
chriscanin Mar 10, 2026
4461fa2
feat(expo): add Android 2-way JS↔native session sync for native compo…
chriscanin Mar 10, 2026
96ca76d
refactor(expo): remove verbose debug logging from session sync code
chriscanin Mar 10, 2026
3b1d3ee
fix(expo): use user-provided tokenCache for native session sync
chriscanin Mar 10, 2026
edd9d86
Merge branch 'main' into chris/mobile-460-add-two-way-jsnative-sessio…
chriscanin Mar 10, 2026
78b41dd
fix(expo): defer NativeSessionSync flag until sync actually succeeds
chriscanin Mar 11, 2026
bdd15bd
Merge branch 'main' into chris/mobile-460-add-two-way-jsnative-sessio…
chriscanin Mar 11, 2026
39d3e70
fix(expo): add iOS getClientToken bridge method and pass keychain con…
chriscanin Mar 11, 2026
118eb56
fix(expo): always configure native SDK on launch and harden keychain …
chriscanin Mar 11, 2026
4f8bd2d
fix(expo): replace reflection hack with Clerk.updateDeviceToken() API
chriscanin Mar 11, 2026
751e803
Merge branch 'main' into chris/mobile-460-add-two-way-jsnative-sessio…
chriscanin Mar 11, 2026
c87fe1d
refactor(expo): simplify ClerkViewFactory (#8046)
mikepitre Mar 11, 2026
137f430
fix(expo): clear native session when JS auth signs out
chriscanin Mar 11, 2026
79215bd
chore(expo): add changeset for native session sync fixes
chriscanin Mar 11, 2026
dea03dc
fix(expo): observe native session directly for inline AuthView sync o…
chriscanin Mar 12, 2026
bc0d928
Merge branch 'main' into chris/mobile-460-add-two-way-jsnative-sessio…
chriscanin Mar 13, 2026
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about the use of reflection here. Generally considered an anti pattern in Java world

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clerk.initialize() is a no-op if the SDK is already initialized, and the Clerk Android SDK doesn't expose any public API to force a re-initialization or swap the device token post-init. We need to trigger a fresh client/environment fetch after writing a new bearer token to SharedPreferences, and there's no way to do that (as far as I can tell) from the android SDK.

The reflection approach finds ConfigurationManager by type (to be resilient to field name changes across SDK versions), then finds the _isInitialized MutableStateFlow and flips it to false so reinitialize() will proceed.

Let me know if you can think of a cleaner way to do it, and I'll put it in!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add those hooks for you in the Android SDK to avoid it if you'd like?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've pushed a commit that drops the entire reflection hack and uses updateDeviceToken() instead. It saves the token via StorageHelper and triggers refreshClientAndEnvironment() internally.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Things are working well. I am going to approve the PR here: clerk/clerk-android#564 (review)
If you can publish to maven, that would be amazing!

* 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,
Comment on lines 199 to 201
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore iOS getClientToken bridge method or remove all callers

getClientToken was removed from the Swift module, but it is still part of the exported bridge contract (ios/ClerkExpoModule.m) and is still used by JS native-auth sync paths (AuthView/InlineAuthView). On iOS this creates a runtime contract mismatch, so calling ClerkExpo.getClientToken() can fail at runtime and prevents JS from receiving the native client token after native sign-in.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

A fix has been provided for this now as well. getClientToken exists in both ClerkExpoModule.swift (as @objc func getClientToken) and the bridge file ClerkExpoModule.m (as RCT_EXTERN_METHOD). It's also defined in ClerkViewFactoryProtocol and implemented in ClerkViewFactory where it delegates to readNativeDeviceToken().

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