Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/fuzzy-keys-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/expo": patch
---

Fix iOS native Clerk components and `useNativeSession()` staying signed out after a user signs in through the Expo JavaScript SDK.
5 changes: 5 additions & 0 deletions .changeset/quiet-ravens-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Fix iOS standalone session persistence after JS-owned sign-in by syncing the JS client token through ClerkKit's native device-token integration API, and keep `useNativeSession()` in sync after native client refreshes.
43 changes: 40 additions & 3 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -25,10 +31,38 @@ 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
}

public protocol ClerkNativeBridgeReadyObserver: AnyObject {
func clerkNativeBridgeDidBecomeReady()
}

private let clerkNativeBridgeReadyObservers = NSHashTable<AnyObject>.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

@objc(ClerkExpo)
Expand Down Expand Up @@ -110,7 +144,10 @@ class ClerkExpoModule: RCTEventEmitter {
return
}

resolve(bridge.getClientToken())
Task {
let token = await bridge.getClientToken()
resolve(token)
}
}

// MARK: - refreshClient
Expand Down
181 changes: 35 additions & 146 deletions packages/expo/ios/ClerkNativeBridge.swift
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -27,13 +26,6 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
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 init() {}

/// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first
Expand All @@ -45,11 +37,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()
Expand All @@ -64,29 +51,17 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
Self.configuredPublishableKey = publishableKey
startClientObserver(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()
let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken)
await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession)
emitClerkNativeBridgeReady()
return
}

Expand All @@ -95,7 +70,9 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions())
startClientObserver()

await Self.waitForLoadedSession()
let shouldWaitForSession = try await Self.syncTokenState(bearerToken: bearerToken)
await Self.waitForLoadedSessionIfNeeded(shouldWaitForSession)
emitClerkNativeBridgeReady()
}

@MainActor
Expand Down Expand Up @@ -129,30 +106,13 @@ 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()
}
return
@MainActor
private static func syncTokenState(bearerToken: String?) async throws -> Bool {
guard let token = bearerToken, !token.isEmpty else {
return Clerk.shared.deviceToken != nil
}

syncJSTokenToNativeKeychainIfNeeded()
}

private static func shouldRefreshConfiguredClient(for bearerToken: String?) -> Bool {
clerkConfigured && !(bearerToken?.isEmpty ?? true)
_ = try await Clerk.shared.updateDeviceToken(token)
return true
}

private static func shouldReconfigure(for publishableKey: String) -> Bool {
Expand All @@ -179,39 +139,16 @@ 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)
}

/// 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) {
keychain?.set(token, forKey: KeychainKey.nativeDeviceToken)
@MainActor
private static func waitForLoadedSessionIfNeeded(_ shouldWait: Bool) async {
guard shouldWait else { return }
await waitForLoadedSession()
}

public func getClientToken() -> String? {
Self.readNativeDeviceToken()
@MainActor
public func getClientToken() async -> String? {
guard Self.clerkConfigured else { return nil }
return Clerk.shared.deviceToken
}

// MARK: - Inline View Creation
Expand All @@ -221,7 +158,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,
Expand All @@ -236,7 +175,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,
Expand All @@ -247,7 +188,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
Expand All @@ -268,6 +211,7 @@ public final class ClerkNativeBridge: ClerkNativeBridgeProtocol {
guard Self.clerkConfigured else { return }
_ = try await Clerk.shared.refreshClient()
await Self.waitForLoadedSession()
emitClerkNativeBridgeReady()
}

private static func authMode(from mode: String) -> AuthView.Mode {
Expand Down Expand Up @@ -410,61 +354,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 {
Expand Down
8 changes: 7 additions & 1 deletion packages/expo/ios/ClerkNativeViewHost.swift
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -19,13 +19,15 @@ public class ClerkNativeViewHost: UIView {
if hasInitialized {
hostedViewDidDetachFromWindow()
}
removeClerkNativeBridgeReadyObserver(self)
hostingCoordinator.detach()
hasInitialized = false
return
}

guard !hasInitialized else { return }
hasInitialized = true
addClerkNativeBridgeReadyObserver(self)
hostedViewDidAttachToWindow()
updateHostedView()
}
Expand All @@ -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)
Expand Down
Loading
Loading