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 c329174372d..04a96a82da2 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 @@ -27,6 +27,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 @@ -53,6 +55,12 @@ private fun debugLog(tag: String, message: String) { } } +internal class ConfigureOptions : Record { + @Field val bearerToken: String? = null + + @Field val proxyUrl: String? = null +} + internal fun biometricCredentialAvailabilityPayload( availability: BiometricCredentialAvailability ): Map { @@ -204,6 +212,7 @@ class ClerkExpoModule : Module() { private var lastObservedClientState: ClientStateSnapshot? = null private var jsOriginatedClientSyncDepth = 0 private var configuredPublishableKey: String? = null + private var configuredProxyUrl: String? = null private data class AuthFlowStateSnapshot( val isLoaded: Boolean, @@ -255,8 +264,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 -> @@ -326,7 +340,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) @@ -336,7 +350,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) } @@ -455,7 +469,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 @@ -464,6 +478,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 @@ -475,7 +490,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 @@ -520,6 +535,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) } @@ -527,8 +543,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) @@ -573,6 +590,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 a1a12679066..e27953ddac4 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 { @@ -36,8 +43,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 @@ -124,10 +136,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 377cf304efa..e9527c63fd8 100644 --- a/packages/expo/ios/ClerkNativeBridge.swift +++ b/packages/expo/ios/ClerkNativeBridge.swift @@ -435,6 +435,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? @@ -478,7 +479,7 @@ 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 @@ -495,12 +496,16 @@ final class ClerkNativeBridge { } } + 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) startAuthFlowObserver(reset: true) @@ -529,7 +534,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() startAuthFlowObserver() @@ -699,17 +705,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(), @@ -100,6 +102,7 @@ vi.mock('../../specs/NativeClerkModule', () => { default: { addListener: vi.fn(), configure: mocks.configure, + configureWithOptions: mocks.configureWithOptions, getClientToken: mocks.getClientToken, syncClientStateFromJs: mocks.syncClientStateFromJs, }, @@ -143,7 +146,10 @@ describe('ClerkProvider native client sync', () => { __internal_resetNativeClientSyncCoordinator(); 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); @@ -201,7 +207,7 @@ describe('ClerkProvider native client sync', () => { await Promise.resolve(); }); - expect(mocks.configure).not.toHaveBeenCalled(); + expect(mocks.configureWithOptions).not.toHaveBeenCalled(); expect(mocks.getClientToken).not.toHaveBeenCalled(); expect(mocks.clerkInstance.addListener).not.toHaveBeenCalled(); expect(mocks.clerkInstance.handleUnauthenticated).toBe(originalHandleUnauthenticated); @@ -217,7 +223,7 @@ describe('ClerkProvider native client sync', () => { const configure = deferred(); mocks.tokenCache.getToken.mockResolvedValue('client-token'); mocks.getClientToken.mockResolvedValue('client-token'); - mocks.configure.mockReturnValue(configure.promise); + mocks.configureWithOptions.mockReturnValue(configure.promise); render( @@ -229,9 +235,12 @@ 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); let didFinishWaiting = false; const waiting = waitForPendingJsToNativeSync().then(() => { didFinishWaiting = true; @@ -249,7 +258,7 @@ describe('ClerkProvider native client sync', () => { test('registers native bootstrap before child effects can await synchronization', async () => { const configure = deferred(); - mocks.configure.mockReturnValue(configure.promise); + mocks.configureWithOptions.mockReturnValue(configure.promise); let didFinishWaiting = false; function Child() { @@ -271,7 +280,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 Promise.resolve(); expect(didFinishWaiting).toBe(false); @@ -284,6 +293,45 @@ describe('ClerkProvider native client sync', () => { }); }); + 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'; @@ -299,7 +347,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(); @@ -315,7 +363,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(); @@ -324,7 +372,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'); @@ -336,9 +384,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(() => { @@ -359,7 +410,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(); }); @@ -392,7 +443,7 @@ describe('ClerkProvider native client sync', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); const configureError = new Error('native refresh failed'); const firstConfigure = rejectableDeferred(); - mocks.configure.mockReturnValueOnce(firstConfigure.promise).mockRejectedValue(configureError); + mocks.configureWithOptions.mockReturnValueOnce(firstConfigure.promise).mockRejectedValue(configureError); const { rerender } = render( { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledTimes(1); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1); expect(mocks.clerkInstance.addListener).toHaveBeenCalled(); }); @@ -411,7 +462,7 @@ describe('ClerkProvider native client sync', () => { firstConfigure.reject(configureError); }); await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError); - expect(mocks.configure).toHaveBeenCalledTimes(2); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(2); mocks.nativeClientEvent = { issuedAt: 1, @@ -433,7 +484,7 @@ describe('ClerkProvider native client sync', () => { test('does not wait for an obsolete native bootstrap after switching publishable keys', async () => { const obsoleteConfigure = deferred(); - mocks.configure.mockReturnValueOnce(obsoleteConfigure.promise).mockResolvedValueOnce(undefined); + mocks.configureWithOptions.mockReturnValueOnce(obsoleteConfigure.promise).mockResolvedValueOnce(undefined); const { rerender } = render( { />, ); - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null)); + await waitFor(() => + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }), + ); rerender( { />, ); - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null)); + await waitFor(() => + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_456', { bearerToken: null, proxyUrl: null }), + ); await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); - expect(mocks.configure).toHaveBeenCalledTimes(2); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(2); + }); + + test('reconfigures native when the proxyUrl changes or is removed', async () => { + const { rerender } = render( + , + ); + + await waitFor(() => + expect(mocks.configureWithOptions).toHaveBeenLastCalledWith('pk_test_123', { + bearerToken: null, + proxyUrl: 'https://example.com/api/__clerk', + }), + ); + + rerender( + , + ); + + await waitFor(() => + expect(mocks.configureWithOptions).toHaveBeenLastCalledWith('pk_test_123', { + bearerToken: null, + proxyUrl: 'https://other.example.com/api/__clerk', + }), + ); + + rerender( + , + ); + + await waitFor(() => + expect(mocks.configureWithOptions).toHaveBeenLastCalledWith('pk_test_123', { + bearerToken: null, + proxyUrl: null, + }), + ); + await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); + expect(mocks.configureWithOptions).toHaveBeenCalledTimes(3); }); test('does not wait for an active native refresh after switching publishable keys', async () => { @@ -467,7 +570,9 @@ describe('ClerkProvider native client sync', () => { />, ); - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', null)); + await waitFor(() => + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { bearerToken: null, proxyUrl: null }), + ); await waitForPendingJsToNativeSync(); mocks.syncClientStateFromJs.mockReturnValueOnce(obsoleteRefresh.promise); @@ -490,7 +595,9 @@ describe('ClerkProvider native client sync', () => { />, ); - await waitFor(() => expect(mocks.configure).toHaveBeenCalledWith('pk_test_456', null)); + await waitFor(() => + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_456', { bearerToken: null, proxyUrl: null }), + ); await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); obsoleteRefresh.reject(obsoleteRefreshError); @@ -501,7 +608,7 @@ describe('ClerkProvider native client sync', () => { test('retries a transient native configure failure', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); const configureError = new Error('transient native refresh failure'); - mocks.configure.mockRejectedValueOnce(configureError); + mocks.configureWithOptions.mockRejectedValueOnce(configureError); const { rerender } = render( { />, ); - await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(mocks.configureWithOptions).toHaveBeenCalledTimes(2)); await expect(waitForPendingJsToNativeSync()).resolves.toBeUndefined(); mocks.nativeClientEvent = { @@ -542,9 +649,9 @@ describe('ClerkProvider native client sync', () => { />, ); - await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mocks.configureWithOptions).toHaveBeenCalledTimes(1)); await waitForPendingJsToNativeSync(); - mocks.configure.mockRejectedValue(configureError); + mocks.configureWithOptions.mockRejectedValue(configureError); mocks.tokenCache.saveToken.mockClear(); mocks.syncClientStateFromJs.mockClear(); @@ -555,7 +662,7 @@ describe('ClerkProvider native client sync', () => { />, ); - await waitFor(() => expect(mocks.configure).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(mocks.configureWithOptions).toHaveBeenCalledTimes(2)); await expect(waitForPendingJsToNativeSync()).rejects.toBe(configureError); mocks.nativeClientEvent = { @@ -570,7 +677,7 @@ describe('ClerkProvider native client sync', () => { />, ); - expect(mocks.configure).toHaveBeenLastCalledWith('pk_test_456', null); + expect(mocks.configureWithOptions).toHaveBeenLastCalledWith('pk_test_456', { bearerToken: null, proxyUrl: null }); expect(mocks.tokenCache.saveToken).not.toHaveBeenCalledWith(CLERK_CLIENT_JWT_KEY, 'native-client-token'); expect(mocks.syncClientStateFromJs).not.toHaveBeenCalled(); @@ -591,8 +698,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); }); @@ -626,7 +733,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(); @@ -649,7 +756,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -686,7 +793,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -724,7 +831,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.__internal_reloadInitialResources.mockClear(); @@ -762,7 +869,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.syncClientStateFromJs.mockClear(); @@ -808,7 +915,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(); @@ -906,7 +1013,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.nativeClientEvent = { @@ -969,7 +1076,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.setActive.mockClear(); @@ -1028,7 +1135,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); mocks.clerkInstance.setActive.mockClear(); @@ -1113,7 +1220,7 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalled(); + expect(mocks.configureWithOptions).toHaveBeenCalled(); }); originalUpdateClient.mockClear(); @@ -1181,7 +1288,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); @@ -1233,7 +1340,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); @@ -1349,7 +1456,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); @@ -1430,7 +1537,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); @@ -1477,7 +1584,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); @@ -1515,7 +1622,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); @@ -1558,7 +1665,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); @@ -1617,7 +1724,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); @@ -1655,7 +1762,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(); @@ -1686,7 +1793,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(() => { @@ -1724,7 +1831,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 () => { @@ -1759,7 +1866,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(); @@ -1784,7 +1891,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 }); }); const nativeSync = deferred(); @@ -1823,7 +1930,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.mockRejectedValueOnce(error); @@ -1866,7 +1973,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 waitForPendingJsToNativeSync(); @@ -1937,7 +2044,10 @@ describe('ClerkProvider native client sync', () => { ); await waitFor(() => { - expect(mocks.configure).toHaveBeenCalledWith('pk_test_123', 'native-client-token'); + expect(mocks.configureWithOptions).toHaveBeenCalledWith('pk_test_123', { + bearerToken: 'native-client-token', + proxyUrl: null, + }); }); fetchClient.mockClear(); @@ -1970,7 +2080,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(); @@ -2066,7 +2176,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(); @@ -2127,7 +2240,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(); @@ -2186,7 +2302,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(); @@ -2249,7 +2368,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(); @@ -2308,7 +2430,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); @@ -2363,7 +2488,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 5f40ed5287c..7ba2778cb5d 100644 --- a/packages/expo/src/provider/nativeClientSync.tsx +++ b/packages/expo/src/provider/nativeClientSync.tsx @@ -60,6 +60,7 @@ type NativeClientBootstrapRegistration = { clerkInstance: SyncableClerkInstance | null | undefined; generation: number; invalidateTracking: () => void; + proxyUrl: string | null; publishableKey: string; tokenCache: TokenCache | undefined; }; @@ -1036,6 +1037,7 @@ function waitForClerkInstanceLoad(clerkInstance: SyncableClerkInstance): Promise export function useNativeClientBootstrap({ enabled, publishableKey, + proxyUrl, nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, @@ -1043,6 +1045,7 @@ export function useNativeClientBootstrap({ }: { enabled: boolean; publishableKey: string; + proxyUrl?: string | ((url: URL) => string); nativeRefreshFromJsControllerRef: MutableRefObject; suppressTokenCacheNotificationsRef: MutableRefObject; tokenCache: TokenCache | undefined; @@ -1051,7 +1054,10 @@ export function useNativeClientBootstrap({ const activeBootstrapRef = useRef(null); const bootstrapGenerationRef = useRef(0); 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 ?? ''}`; useNativeClientBootstrapEffect(() => { isMountedRef.current = true; @@ -1060,26 +1066,29 @@ export function useNativeClientBootstrap({ const canReuseActiveBootstrap = canBootstrap && activeBootstrap?.publishableKey === publishableKey && + activeBootstrap.proxyUrl === nativeProxyUrl && activeBootstrap.clerkInstance === clerkInstance && activeBootstrap.tokenCache === tokenCache; if (activeBootstrap && !canReuseActiveBootstrap) { activeBootstrap.invalidateTracking(); activeBootstrapRef.current = null; - setReadyPublishableKey(null); + setReadyConfigKey(null); } if (canBootstrap && !activeBootstrapRef.current) { const configuringPublishableKey = publishableKey; + const configuringConfigKey = configKey; const bootstrapRegistration: NativeClientBootstrapRegistration = { clerkInstance, generation: ++bootstrapGenerationRef.current, invalidateTracking: () => undefined, + proxyUrl: nativeProxyUrl, publishableKey: configuringPublishableKey, tokenCache, }; activeBootstrapRef.current = bootstrapRegistration; - setReadyPublishableKey(null); + setReadyConfigKey(null); const isCurrentConfiguration = () => isMountedRef.current && activeBootstrapRef.current === bootstrapRegistration && @@ -1111,7 +1120,21 @@ export function useNativeClientBootstrap({ return; } - await ClerkExpo.configure(configuringPublishableKey, initialJsDeviceToken); + if (typeof ClerkExpo.configureWithOptions === 'function') { + await ClerkExpo.configureWithOptions(configuringPublishableKey, { + 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(configuringPublishableKey, initialJsDeviceToken); + } if (!isCurrentConfiguration()) { return; @@ -1154,7 +1177,7 @@ export function useNativeClientBootstrap({ } if (isCurrentConfiguration()) { - setReadyPublishableKey(configuringPublishableKey); + setReadyConfigKey(configuringConfigKey); } } } catch (error) { @@ -1219,6 +1242,8 @@ export function useNativeClientBootstrap({ }, [ enabled, publishableKey, + configKey, + nativeProxyUrl, nativeRefreshFromJsControllerRef, suppressTokenCacheNotificationsRef, tokenCache, @@ -1227,7 +1252,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 d5c59ceb1c9..8b9a4f0c9eb 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -7,6 +7,11 @@ interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialModule { // 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 7e71393a9be..69eb13bfc1e 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -7,6 +7,11 @@ export interface Spec extends NativeAuthFlowModule, NativeBiometricCredentialMod // 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 11751ec4114..8daf66e9a4d 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -8,6 +8,10 @@ export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'andro export 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,