Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/expo-native-proxy-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/expo': patch
---

Native components now respect the `proxyUrl` passed to `<ClerkProvider>` 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?,
Expand Down Expand Up @@ -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 ->
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -278,15 +293,17 @@ 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)
}
return@launch
}

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)
Expand Down Expand Up @@ -331,6 +348,7 @@ class ClerkExpoModule : Module() {
}

configuredPublishableKey = pubKey
configuredProxyUrl = normalizedProxyUrl
lastObservedClientState = clientStateSnapshot()
promise.resolve(null)
return@launch
Expand Down
19 changes: 16 additions & 3 deletions packages/expo/ios/ClerkExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
Expand Down
31 changes: 22 additions & 9 deletions packages/expo/ios/ClerkNativeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/expo/src/provider/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export function ClerkProvider<TUi extends Ui = Ui>(props: ClerkProviderProps<TUi
const { isMountedRef, isNativeClientReady } = useNativeClientBootstrap({
enabled: nativeClientSyncEnabled,
publishableKey: pk,
proxyUrl,
nativeRefreshFromJsControllerRef,
suppressTokenCacheNotificationsRef,
tokenCache: syncableTokenCache,
Expand Down
Loading
Loading