Skip to content

Commit 118eb56

Browse files
committed
fix(expo): always configure native SDK on launch and harden keychain access
- Remove early return when no bearer token — the iOS SDK must be configured on launch so Clerk.shared is initialized before any getSession() calls. Without this, a fresh install (empty keychain) would crash with a fatalError when NativeSessionSync tried to check for a native session. - Add clerkConfigured guard to getSession() and signOut() to prevent accessing Clerk.shared before configure() has been called. - Pass keychain config (ClerkKeychainService from Info.plist or bundle ID) to Clerk.configure() so the native SDK uses the correct keychain service. - Use configurable keychainService for all keychain operations instead of hardcoded Bundle.main.bundleIdentifier. - Handle re-configure with refreshClient() when called with a new token after initial configuration. - Always update (not skip) the native device token on re-sign-in, and clear stale cached client/environment data when the token changes.
1 parent 39d3e70 commit 118eb56

2 files changed

Lines changed: 104 additions & 29 deletions

File tree

packages/expo/ios/templates/ClerkViewFactory.swift

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol {
1616

1717
private static let clerkLoadMaxAttempts = 30
1818
private static let clerkLoadIntervalNs: UInt64 = 100_000_000
19+
private static var clerkConfigured = false
20+
21+
/// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first
22+
/// (for extension apps sharing a keychain group), then falling back to the bundle identifier.
23+
private static var keychainService: String? {
24+
if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty {
25+
return custom
26+
}
27+
return Bundle.main.bundleIdentifier
28+
}
1929

2030
private init() {}
2131

@@ -30,12 +40,38 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol {
3040
// This handles the case where the user signed in via JS SDK but the native SDK
3141
// has no device token (e.g., after app reinstall or first launch).
3242
if let token = bearerToken, !token.isEmpty {
33-
Self.writeNativeDeviceTokenIfNeeded(token)
43+
let existingToken = Self.readNativeDeviceToken()
44+
Self.writeNativeDeviceToken(token)
45+
46+
// If the device token changed (or didn't exist), clear stale cached client/environment.
47+
// A previous launch may have cached an anonymous client (no device token), and the
48+
// SDK would send both the new device token AND the stale client ID in API requests,
49+
// causing a 400 error. Clearing the cache forces a fresh client fetch using only
50+
// the device token.
51+
if existingToken != token {
52+
Self.clearCachedClerkData()
53+
}
3454
} else {
3555
Self.syncJSTokenToNativeKeychainIfNeeded()
3656
}
3757

38-
Clerk.configure(publishableKey: publishableKey)
58+
// If already configured with a new bearer token, refresh the client
59+
// to pick up the session associated with the device token we just wrote.
60+
// Clerk.configure() is a no-op on subsequent calls, so we use refreshClient().
61+
if Self.clerkConfigured, let token = bearerToken, !token.isEmpty {
62+
_ = try? await Clerk.shared.refreshClient()
63+
return
64+
}
65+
66+
Self.clerkConfigured = true
67+
if let service = Self.keychainService {
68+
Clerk.configure(
69+
publishableKey: publishableKey,
70+
options: .init(keychainConfig: .init(service: service))
71+
)
72+
} else {
73+
Clerk.configure(publishableKey: publishableKey)
74+
}
3975

4076
// Wait for Clerk to finish loading (cached data + API refresh).
4177
// The static configure() fires off async refreshes; poll until loaded.
@@ -52,7 +88,7 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol {
5288
/// Both expo-secure-store and the native Clerk SDK use the iOS Keychain with the
5389
/// bundle identifier as the service name, making cross-SDK token sharing possible.
5490
private static func syncJSTokenToNativeKeychainIfNeeded() {
55-
guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return }
91+
guard let service = keychainService, !service.isEmpty else { return }
5692

5793
let jsTokenKey = "__clerk_client_jwt"
5894
let nativeTokenKey = "clerkDeviceToken"
@@ -97,35 +133,78 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol {
97133
SecItemAdd(writeQuery as CFDictionary, nil)
98134
}
99135

100-
/// Writes the provided bearer token as the native SDK's device token,
101-
/// but only if the native SDK doesn't already have one.
102-
private static func writeNativeDeviceTokenIfNeeded(_ token: String) {
103-
guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return }
136+
/// Reads the native device token from keychain, if present.
137+
private static func readNativeDeviceToken() -> String? {
138+
guard let service = keychainService, !service.isEmpty else { return nil }
104139

105-
let nativeTokenKey = "clerkDeviceToken"
106-
107-
// Check if native SDK already has a device token — don't overwrite
108-
let checkQuery: [String: Any] = [
140+
var result: CFTypeRef?
141+
let query: [String: Any] = [
109142
kSecClass as String: kSecClassGenericPassword,
110143
kSecAttrService as String: service,
111-
kSecAttrAccount as String: nativeTokenKey,
112-
kSecReturnData as String: false,
144+
kSecAttrAccount as String: "clerkDeviceToken",
145+
kSecReturnData as String: true,
113146
kSecMatchLimit as String: kSecMatchLimitOne,
114147
]
115-
if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess {
116-
return
148+
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
149+
let data = result as? Data else { return nil }
150+
return String(data: data, encoding: .utf8)
151+
}
152+
153+
/// Clears stale cached client and environment data from keychain.
154+
/// This prevents the native SDK from loading a stale anonymous client
155+
/// during initialization, which would conflict with a newly-synced device token.
156+
private static func clearCachedClerkData() {
157+
guard let service = keychainService, !service.isEmpty else { return }
158+
159+
for key in ["cachedClient", "cachedEnvironment"] {
160+
let query: [String: Any] = [
161+
kSecClass as String: kSecClassGenericPassword,
162+
kSecAttrService as String: service,
163+
kSecAttrAccount as String: key,
164+
]
165+
SecItemDelete(query as CFDictionary)
117166
}
167+
}
118168

119-
// Write the provided token as native device token
169+
/// Writes the provided bearer token as the native SDK's device token.
170+
/// If the native SDK already has a device token, it is updated with the new value.
171+
private static func writeNativeDeviceToken(_ token: String) {
172+
guard let service = keychainService, !service.isEmpty else { return }
173+
174+
let nativeTokenKey = "clerkDeviceToken"
120175
guard let tokenData = token.data(using: .utf8) else { return }
121-
let writeQuery: [String: Any] = [
176+
177+
// Check if native SDK already has a device token
178+
let checkQuery: [String: Any] = [
122179
kSecClass as String: kSecClassGenericPassword,
123180
kSecAttrService as String: service,
124181
kSecAttrAccount as String: nativeTokenKey,
125-
kSecValueData as String: tokenData,
126-
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
182+
kSecReturnData as String: false,
183+
kSecMatchLimit as String: kSecMatchLimitOne,
127184
]
128-
SecItemAdd(writeQuery as CFDictionary, nil)
185+
186+
if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess {
187+
// Update the existing token
188+
let updateQuery: [String: Any] = [
189+
kSecClass as String: kSecClassGenericPassword,
190+
kSecAttrService as String: service,
191+
kSecAttrAccount as String: nativeTokenKey,
192+
]
193+
let updateAttributes: [String: Any] = [
194+
kSecValueData as String: tokenData,
195+
]
196+
SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
197+
} else {
198+
// Write a new token
199+
let writeQuery: [String: Any] = [
200+
kSecClass as String: kSecClassGenericPassword,
201+
kSecAttrService as String: service,
202+
kSecAttrAccount as String: nativeTokenKey,
203+
kSecValueData as String: tokenData,
204+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
205+
]
206+
SecItemAdd(writeQuery as CFDictionary, nil)
207+
}
129208
}
130209

131210
public func createAuthViewController(
@@ -206,7 +285,7 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol {
206285

207286
@MainActor
208287
public func getSession() async -> [String: Any]? {
209-
guard let session = Clerk.shared.session else {
288+
guard Self.clerkConfigured, let session = Clerk.shared.session else {
210289
return nil
211290
}
212291

@@ -242,7 +321,7 @@ public class ClerkViewFactory: ClerkViewFactoryProtocol {
242321

243322
@MainActor
244323
public func signOut() async throws {
245-
guard let sessionId = Clerk.shared.session?.id else { return }
324+
guard Self.clerkConfigured, let sessionId = Clerk.shared.session?.id else { return }
246325
try await Clerk.shared.auth.signOut(sessionId: sessionId)
247326
}
248327
}

packages/expo/src/provider/ClerkProvider.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,9 @@ export function ClerkProvider<TUi extends Ui = Ui>(props: ClerkProviderProps<TUi
183183
}
184184
}
185185

186-
// Only configure native SDK if we have a bearer token.
187-
// Configuring without a token creates an anonymous native client,
188-
// which conflicts when we later try to inject the JS SDK's token.
189-
if (!bearerToken) {
190-
return;
191-
}
192-
186+
// Always configure the native SDK on launch, even without a token.
187+
// The iOS SDK requires Clerk.configure() before Clerk.shared can be accessed.
188+
// If we have a bearer token, pass it so the native SDK picks up the JS session.
193189
await ClerkExpo.configure(pk, bearerToken);
194190

195191
if (!isMountedRef.current) {

0 commit comments

Comments
 (0)