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 326d9b7d8f2..d780e3c3c22 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 @@ -18,6 +18,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 @@ -39,12 +41,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?, @@ -88,8 +97,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 -> @@ -115,7 +129,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) @@ -125,7 +139,7 @@ class ClerkExpoModule : Module() { } // JS owns client state. The native foreground refresh races SSO completion and mints duplicate clients (#9217). - return ClerkConfigurationOptions() + return ClerkConfigurationOptions(proxyUrl = proxyUrl) .withForegroundRefreshDisabled() .withCustomHeaders(customHeaders) } @@ -213,7 +227,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 @@ -222,6 +236,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 @@ -233,7 +248,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 @@ -278,6 +293,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) } @@ -285,8 +301,9 @@ class ClerkExpoModule : Module() { } val activePublishableKey = configuredPublishableKey ?: Clerk.publishableKey - if (activePublishableKey != null && activePublishableKey != pubKey) { - Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions()) + val activeProxyUrl = configuredProxyUrl ?: Clerk.proxyUrl + if (activePublishableKey != null && (activePublishableKey != pubKey || activeProxyUrl != normalizedProxyUrl)) { + Clerk.switchConfiguration(context, pubKey, clerkConfigurationOptions(normalizedProxyUrl)) startClientStateObserver() appContext.currentActivity?.let { Clerk.attachActivity(it) } loadThemeFromAssets(context) @@ -331,6 +348,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 c159d70c343..77fc4004d4c 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -194,6 +194,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? @@ -228,19 +229,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) @@ -265,7 +270,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) @@ -354,17 +360,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 8eb900d80f0..1a807b351a7 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -102,6 +102,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); @@ -213,13 +219,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'; @@ -235,7 +283,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(); @@ -251,7 +299,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(); @@ -260,7 +308,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'); @@ -272,9 +320,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(() => { @@ -295,7 +346,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(); }); @@ -326,7 +377,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(() => { @@ -364,8 +415,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); }); @@ -399,7 +450,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(); @@ -422,7 +473,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -459,7 +510,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -497,7 +548,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -535,7 +586,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.syncClientStateFromJs.mockClear(); @@ -581,7 +632,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(); @@ -677,7 +728,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.nativeClientEvent = { @@ -740,7 +791,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.setActive.mockClear(); @@ -799,7 +850,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.setActive.mockClear(); @@ -884,7 +935,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); originalUpdateClient.mockClear(); @@ -952,7 +1003,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); @@ -1004,7 +1055,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); @@ -1120,7 +1171,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); @@ -1201,7 +1252,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); @@ -1248,7 +1299,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); @@ -1286,7 +1337,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); @@ -1329,7 +1380,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); @@ -1383,7 +1434,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); @@ -1416,7 +1467,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(); @@ -1447,7 +1498,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(() => { @@ -1485,7 +1536,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 () => { @@ -1520,7 +1571,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(); @@ -1545,7 +1596,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(); @@ -1641,7 +1692,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(); @@ -1702,7 +1756,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(); @@ -1761,7 +1818,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(); @@ -1824,7 +1884,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(); @@ -1883,7 +1946,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); @@ -1938,7 +2004,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 6eb91627a9a..9add9ad30db 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -964,6 +964,7 @@ function waitForClerkInstanceLoad(clerkInstance: SyncableClerkInstance): Promise export function useNativeClientBootstrap({ enabled, publishableKey, + proxyUrl, nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, @@ -971,14 +972,18 @@ export function useNativeClientBootstrap({ }: { enabled: boolean; 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; @@ -987,12 +992,11 @@ export function useNativeClientBootstrap({ enabled && (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; @@ -1022,7 +1026,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; @@ -1076,7 +1094,7 @@ export function useNativeClientBootstrap({ } } finally { if (didAttemptConfigure && isCurrentConfiguration()) { - setReadyPublishableKey(configuringPublishableKey); + setReadyConfigKey(configuringConfigKey); } } }; @@ -1089,6 +1107,8 @@ export function useNativeClientBootstrap({ }, [ enabled, publishableKey, + configKey, + nativeProxyUrl, nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, @@ -1097,7 +1117,7 @@ export function useNativeClientBootstrap({ 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,