diff --git a/.changeset/expo-native-proxy-url.md b/.changeset/expo-native-proxy-url.md new file mode 100644 index 00000000000..702e2f6d333 --- /dev/null +++ b/.changeset/expo-native-proxy-url.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo': patch +--- + +Native components now respect the `proxyUrl` passed to `` and route Frontend API requests through the configured proxy. Applying the proxy requires a new app binary; a JS-only OTA update safely keeps the previous behavior. 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 c5718ade6ef..406d66a6dce 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 @@ -15,6 +15,8 @@ import com.clerk.api.ui.ClerkTheme import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.records.Field +import expo.modules.kotlin.records.Record import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -36,12 +38,19 @@ private fun debugLog(tag: String, message: String) { } } +internal class ConfigureOptions : Record { + @Field val bearerToken: String? = null + + @Field val proxyUrl: String? = null +} + class ClerkExpoModule : Module() { private val coroutineScope = CoroutineScope(Dispatchers.Main) private var clientStateObserverJob: Job? = null private var lastObservedClientState: ClientStateSnapshot? = null private var jsOriginatedClientSyncDepth = 0 private var configuredPublishableKey: String? = null + private var configuredProxyUrl: String? = null private data class ClientStateSnapshot( val client: Client?, @@ -85,8 +94,13 @@ class ClerkExpoModule : Module() { clientStateObserverJob = null } + // Keeps the pre-proxy signature so OTA-updated JS on older binaries keeps working. AsyncFunction("configure") { pubKey: String, bearerToken: String?, promise: Promise -> - configure(pubKey, bearerToken, promise) + configure(pubKey, bearerToken, null, promise) + } + + AsyncFunction("configureWithOptions") { pubKey: String, options: ConfigureOptions, promise: Promise -> + configure(pubKey, options.bearerToken, options.proxyUrl, promise) } AsyncFunction("getClientToken") { promise: Promise -> @@ -112,7 +126,7 @@ class ClerkExpoModule : Module() { private val reactContext: Context? get() = appContext.reactContext - private fun clerkConfigurationOptions(): ClerkConfigurationOptions { + private fun clerkConfigurationOptions(proxyUrl: String?): ClerkConfigurationOptions { val hostSdkVersion = BuildConfig.CLERK_EXPO_VERSION.trim() val customHeaders = buildMap { put(HOST_SDK_HEADER, HOST_SDK) @@ -121,7 +135,7 @@ class ClerkExpoModule : Module() { } } - return ClerkConfigurationOptions().withCustomHeaders(customHeaders) + return ClerkConfigurationOptions(proxyUrl = proxyUrl).withCustomHeaders(customHeaders) } private fun startClientStateObserver() { @@ -207,7 +221,7 @@ class ClerkExpoModule : Module() { // MARK: - configure - private fun configure(pubKey: String, bearerToken: String?, promise: Promise) { + private fun configure(pubKey: String, bearerToken: String?, proxyUrl: String?, promise: Promise) { val context = reactContext ?: run { promise.reject("E_INIT_FAILED", "React context is not available", null) return @@ -216,6 +230,7 @@ class ClerkExpoModule : Module() { coroutineScope.launch { try { val normalizedBearerToken = bearerToken?.trim()?.takeIf { it.isNotEmpty() } + val normalizedProxyUrl = proxyUrl?.trim()?.takeIf { it.isNotEmpty() } if (!Clerk.isInitialized.value) { // First-time initialization — write the bearer token to SharedPreferences @@ -227,7 +242,7 @@ class ClerkExpoModule : Module() { .apply() } - Clerk.initialize(context, pubKey, clerkConfigurationOptions()) + Clerk.initialize(context, pubKey, clerkConfigurationOptions(normalizedProxyUrl)) startClientStateObserver() // clerk-android registers ActivityLifecycleCallbacks during // initialize(), but in React Native MainActivity has already passed @@ -272,6 +287,7 @@ class ClerkExpoModule : Module() { promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}", null) } else { configuredPublishableKey = pubKey + configuredProxyUrl = normalizedProxyUrl lastObservedClientState = clientStateSnapshot() promise.resolve(null) } @@ -279,8 +295,8 @@ class ClerkExpoModule : Module() { } val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey - if (activePublishableKey != null && activePublishableKey != pubKey) { - Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions()) + if (activePublishableKey != null && (activePublishableKey != pubKey || configuredProxyUrl != normalizedProxyUrl)) { + Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions(normalizedProxyUrl)) startClientStateObserver() appContext.currentActivity?.let { Clerk.attachActivity(it) } loadThemeFromAssets(context) @@ -325,6 +341,7 @@ class ClerkExpoModule : Module() { } configuredPublishableKey = pubKey + configuredProxyUrl = normalizedProxyUrl lastObservedClientState = clientStateSnapshot() promise.resolve(null) return@launch diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift index 8c2f964ec2d..65f00bd4852 100644 --- a/packages/expo/ios/ClerkExpoModule.swift +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -5,6 +5,13 @@ import ExpoModulesCore import Foundation +// MARK: - Records + +struct ConfigureOptions: Record { + @Field var bearerToken: String? + @Field var proxyUrl: String? +} + // MARK: - Module public class ClerkExpoModule: Module { @@ -31,8 +38,13 @@ public class ClerkExpoModule: Module { } } + // Keeps the pre-proxy signature so OTA-updated JS on older binaries keeps working. AsyncFunction("configure") { (publishableKey: String, bearerToken: String?, promise: Promise) in - self.configure(publishableKey, bearerToken: bearerToken, promise: promise) + self.configure(publishableKey, bearerToken: bearerToken, proxyUrl: nil, promise: promise) + } + + AsyncFunction("configureWithOptions") { (publishableKey: String, options: ConfigureOptions, promise: Promise) in + self.configure(publishableKey, bearerToken: options.bearerToken, proxyUrl: options.proxyUrl, promise: promise) } AsyncFunction("getClientToken") { (promise: Promise) in @@ -57,10 +69,11 @@ public class ClerkExpoModule: Module { // MARK: - configure - private func configure(_ publishableKey: String, bearerToken: String?, promise: Promise) { + private func configure(_ publishableKey: String, bearerToken: String?, proxyUrl: String?, promise: Promise) { Task { do { - try await ClerkNativeBridge.shared.configure(publishableKey: publishableKey, bearerToken: bearerToken) + try await ClerkNativeBridge.shared.configure( + publishableKey: publishableKey, bearerToken: bearerToken, proxyUrl: proxyUrl) promise.resolve() } catch { promise.reject("E_CONFIGURE_FAILED", error.localizedDescription) diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift index fccd156a501..886d60b2ee4 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -67,6 +67,7 @@ final class ClerkNativeBridge { private static let clerkLoadIntervalNs: UInt64 = 100_000_000 private static var clerkConfigured = false private static var configuredPublishableKey: String? + private static var configuredProxyUrl: String? /// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary. var lightTheme: ClerkTheme? @@ -101,19 +102,23 @@ final class ClerkNativeBridge { } @MainActor - func configure(publishableKey: String, bearerToken: String? = nil) async throws { + func configure(publishableKey: String, bearerToken: String? = nil, proxyUrl: String? = nil) async throws { configurationDepth += 1 defer { lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil configurationDepth = max(0, configurationDepth - 1) } + let normalizedProxyUrl = Self.normalizedProxyUrl(proxyUrl) + loadThemes() - if Self.shouldReconfigure(for: publishableKey) { - try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + if Self.shouldReconfigure(for: publishableKey, proxyUrl: normalizedProxyUrl) { + try await Clerk.reconfigure( + publishableKey: publishableKey, options: Self.makeClerkOptions(proxyUrl: normalizedProxyUrl)) Self.clerkConfigured = true Self.configuredPublishableKey = publishableKey + Self.configuredProxyUrl = normalizedProxyUrl startClientObserver(reset: true) let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) @@ -138,7 +143,8 @@ final class ClerkNativeBridge { Self.clerkConfigured = true Self.configuredPublishableKey = publishableKey - Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions()) + Self.configuredProxyUrl = normalizedProxyUrl + Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions(proxyUrl: normalizedProxyUrl)) startClientObserver() let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken) @@ -227,17 +233,24 @@ final class ClerkNativeBridge { return true } - private static func shouldReconfigure(for publishableKey: String) -> Bool { + private static func shouldReconfigure(for publishableKey: String, proxyUrl: String?) -> Bool { guard clerkConfigured, let configuredPublishableKey else { return false } - return configuredPublishableKey != publishableKey + return configuredPublishableKey != publishableKey || configuredProxyUrl != proxyUrl + } + + private static func normalizedProxyUrl(_ proxyUrl: String?) -> String? { + guard let trimmed = proxyUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed } - private static func makeClerkOptions() -> Clerk.Options { + private static func makeClerkOptions(proxyUrl: String?) -> Clerk.Options { let middleware = Clerk.Options.MiddlewareConfig(request: [ClerkExpoHeaderMiddleware()]) guard let service = keychainService else { - return .init(middleware: middleware) + return .init(proxyUrl: proxyUrl, middleware: middleware) } - return .init(keychainConfig: .init(service: service), middleware: middleware) + return .init(keychainConfig: .init(service: service), proxyUrl: proxyUrl, middleware: middleware) } @MainActor diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index 720f424031c..87ecaa636e2 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -92,6 +92,7 @@ export function ClerkProvider(props: ClerkProviderProps { return { configure: vi.fn(), + configureWithOptions: vi.fn(), getClientToken: vi.fn(), nativeClientEvent: null as unknown, syncClientStateFromJs: vi.fn(), @@ -95,6 +97,7 @@ vi.mock('../../specs/NativeClerkModule', () => { default: { addListener: vi.fn(), configure: mocks.configure, + configureWithOptions: mocks.configureWithOptions, getClientToken: mocks.getClientToken, syncClientStateFromJs: mocks.syncClientStateFromJs, }, @@ -129,7 +132,10 @@ describe('ClerkProvider native client sync', () => { beforeEach(() => { vi.clearAllMocks(); mocks.nativeClientEvent = null; + (NativeClerkModule as unknown as { configureWithOptions?: unknown }).configureWithOptions = + mocks.configureWithOptions; mocks.configure.mockResolvedValue(undefined); + mocks.configureWithOptions.mockResolvedValue(undefined); mocks.getClientToken.mockResolvedValue(null); mocks.syncClientStateFromJs.mockResolvedValue(undefined); mocks.tokenCache.getToken.mockResolvedValue(null); @@ -186,13 +192,55 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: 'client-token', + proxyUrl: null, + }); }); - expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); }); + test('passes the proxyUrl to the native configure call', async () => { + render( + , + ); + + await waitFor(() => { + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: null, + proxyUrl: 'https://example.com/api/__clerk', + }); + }); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); + expect(mocks.configure).not.toHaveBeenCalled(); + }); + + test('falls back to the legacy configure signature when the binary lacks configureWithOptions', async () => { + delete (NativeClerkModule as unknown as { configureWithOptions?: unknown }).configureWithOptions; + mocks.tokenCache.getToken.mockResolvedValue('client-token'); + mocks.getClientToken.mockResolvedValue('client-token'); + + render( + , + ); + + await waitFor(() => { + expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'client-token'); + }); + expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).not.toHaveBeenCalled(); + }); + test('syncs the native device token to JS after Clerk loads during bootstrap', async () => { mocks.clerkInstance.loaded = false; mocks.clerkInstance.status = 'loading'; @@ -208,7 +256,7 @@ describe('ClerkProvider native client sync', () => { await waitFor(() => { expect(mocks.clerkInstance.on).toHaveBeenCalledWith('status', expect.any(Function)); }); - expect(mocks.configure).not.toHaveBeenCalled(); + expect(mocks.configureWithOptions).not.toHaveBeenCalled(); expect(mocks.getClientToken).not.toHaveBeenCalled(); expect(mocks.tokenCache.saveToken).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).not.toHaveBeenCalled(); @@ -224,7 +272,7 @@ describe('ClerkProvider native client sync', () => { }); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); }); expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalled(); @@ -233,7 +281,7 @@ describe('ClerkProvider native client sync', () => { test('syncs a JS token rotated during bootstrap to native exactly once', async () => { const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); + mocks.configureWithOptions.mockReturnValue(configure.promise); mocks.tokenCache.getToken.mockResolvedValueOnce('cached-client-token').mockResolvedValue('rotated-client-token'); mocks.getClientToken.mockResolvedValue('native-client-token'); @@ -245,9 +293,12 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'cached-client-token'); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: 'cached-client-token', + proxyUrl: null, + }); }); - expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); act(() => { @@ -268,7 +319,7 @@ describe('ClerkProvider native client sync', () => { test('flushes one JS client change that occurs after JS loads but before native is ready', async () => { const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); + mocks.configureWithOptions.mockReturnValue(configure.promise); render( { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); }); @@ -299,7 +350,7 @@ describe('ClerkProvider native client sync', () => { test('keeps synchronization enabled when native configure rejects', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - mocks.configure.mockRejectedValue(new Error('native refresh failed')); + mocks.configureWithOptions.mockRejectedValue(new Error('native refresh failed')); render( { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); }); act(() => { @@ -337,8 +388,8 @@ describe('ClerkProvider native client sync', () => { await waitFor(() => { expect(mocks.tokenCache.saveToken).toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); }); - expect(mocks.configure).toHaveBeenCalledTimes(1); - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); expect(mocks.clerkInstance.__internal_reloadInitialResources).toHaveBeenCalledTimes(1); }); @@ -372,7 +423,7 @@ describe('ClerkProvider native client sync', () => { render(); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -395,7 +446,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -432,7 +483,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -470,7 +521,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -508,7 +559,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.syncClientStateFromJs.mockClear(); @@ -554,7 +605,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -650,7 +701,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.nativeClientEvent = { @@ -713,7 +764,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.setActive.mockClear(); @@ -772,7 +823,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.setActive.mockClear(); @@ -857,7 +908,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); originalUpdateClient.mockClear(); @@ -925,7 +976,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -977,7 +1028,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); @@ -1093,7 +1144,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.updateClient).not.toBe(originalUpdateClient); @@ -1174,7 +1225,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1221,7 +1272,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1259,7 +1310,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1302,7 +1353,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1356,7 +1407,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1389,7 +1440,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1420,7 +1471,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); act(() => { @@ -1458,7 +1509,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); await act(async () => { @@ -1493,7 +1544,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1518,7 +1569,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1614,7 +1665,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: jsDeviceToken, + proxyUrl: null, + }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1675,7 +1729,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: jsDeviceToken, + proxyUrl: null, + }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1734,7 +1791,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: jsDeviceToken, + proxyUrl: null, + }); }); mocks.syncClientStateFromJs.mockClear(); @@ -1797,7 +1857,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', jsDeviceToken); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: jsDeviceToken, + proxyUrl: null, + }); }); mocks.tokenCache.saveToken.mockClear(); @@ -1856,7 +1919,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: 'js-device-token', + proxyUrl: null, + }); }); await waitFor(() => { expect(mocks.clerkInstance.handleUnauthenticated).not.toBe(originalHandleUnauthenticated); @@ -1911,7 +1977,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'js-device-token'); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: 'js-device-token', + proxyUrl: null, + }); }); mocks.tokenCache.getToken.mockImplementation(() => new Promise(() => {})); diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx index 5c5bca2c978..7192e732176 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -963,20 +963,25 @@ function waitForClerkInstanceLoad(clerkInstance: SyncableClerkInstance): Promise export function useNativeClientBootstrap({ publishableKey, + proxyUrl, nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, clerkInstance, }: { publishableKey: string; + proxyUrl?: string | ((url: URL) => string); nativeRefreshFromJsControllerRef: MutableRefObject; suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; clerkInstance: SyncableClerkInstance | null | undefined; }) { - const startedPublishableKeyRef = useRef(null); + const startedConfigKeyRef = useRef(null); const isMountedRef = useRef(true); - const [readyPublishableKey, setReadyPublishableKey] = useState(null); + const [readyConfigKey, setReadyConfigKey] = useState(null); + // Function proxyUrls are browser-only; the singleton already rejects them on native. + const nativeProxyUrl = typeof proxyUrl === 'string' && proxyUrl ? proxyUrl : null; + const configKey = `${publishableKey}|${nativeProxyUrl ?? ''}`; useEffect(() => { isMountedRef.current = true; @@ -984,12 +989,11 @@ export function useNativeClientBootstrap({ if ( (Platform.OS === 'ios' || Platform.OS === 'android') && publishableKey && - startedPublishableKeyRef.current !== publishableKey + startedConfigKeyRef.current !== configKey ) { - startedPublishableKeyRef.current = publishableKey; - const configuringPublishableKey = publishableKey; - const isCurrentConfiguration = () => - isMountedRef.current && startedPublishableKeyRef.current === configuringPublishableKey; + startedConfigKeyRef.current = configKey; + const configuringConfigKey = configKey; + const isCurrentConfiguration = () => isMountedRef.current && startedConfigKeyRef.current === configuringConfigKey; const configureNativeClerk = async () => { let didAttemptConfigure = false; @@ -1019,7 +1023,21 @@ export function useNativeClientBootstrap({ } didAttemptConfigure = true; - await ClerkExpo.configure(configuringPublishableKey, initialJsDeviceToken); + if (typeof ClerkExpo.configureWithOptions === 'function') { + await ClerkExpo.configureWithOptions(publishableKey, { + bearerToken: initialJsDeviceToken, + proxyUrl: nativeProxyUrl, + }); + } else { + // Old binaries reject extra configure args, so OTA-updated JS must use the legacy call. + if (nativeProxyUrl && __DEV__) { + console.warn( + '[ClerkProvider] The installed Clerk native module does not support proxyUrl. ' + + 'Rebuild the app binary to route native components through your proxy.', + ); + } + await ClerkExpo.configure(publishableKey, initialJsDeviceToken); + } if (!isCurrentConfiguration()) { return; @@ -1073,7 +1091,7 @@ export function useNativeClientBootstrap({ } } finally { if (didAttemptConfigure && isCurrentConfiguration()) { - setReadyPublishableKey(configuringPublishableKey); + setReadyConfigKey(configuringConfigKey); } } }; @@ -1083,11 +1101,19 @@ export function useNativeClientBootstrap({ return () => { isMountedRef.current = false; }; - }, [publishableKey, nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, clerkInstance]); + }, [ + publishableKey, + configKey, + nativeProxyUrl, + nativeRefreshFromJsControllerRef, + suppressTokenCacheNotificationsRef, + tokenCache, + clerkInstance, + ]); return { isMountedRef, - isNativeClientReady: readyPublishableKey === publishableKey, + isNativeClientReady: readyConfigKey === configKey, }; } diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index cced94ad12a..aa18caebf35 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -5,6 +5,11 @@ interface Spec { // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; + // Absent on binaries built before proxy support; feature-detect and fall back to configure(). + configureWithOptions?( + publishableKey: string, + options: { bearerToken: string | null; proxyUrl: string | null }, + ): Promise; getClientToken(): Promise; syncClientStateFromJs( deviceToken: string | null, diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index c8eb967e84d..390daf07527 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -5,6 +5,11 @@ export interface Spec { // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; + // Absent on binaries built before proxy support; feature-detect and fall back to configure(). + configureWithOptions?( + publishableKey: string, + options: { bearerToken: string | null; proxyUrl: string | null }, + ): Promise; getClientToken(): Promise; syncClientStateFromJs( deviceToken: string | null, diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index 1a852882e4e..14a476c1578 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -7,6 +7,10 @@ export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'andro type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; configure(publishableKey: string, bearerToken: string | null): Promise; + configureWithOptions?( + publishableKey: string, + options: { bearerToken: string | null; proxyUrl: string | null }, + ): Promise; getClientToken(): Promise; syncClientStateFromJs( deviceToken: string | null,