Native Resolvers (React Native)#
The Curity HAAPI React Native SDK uses a small Native Registry / Resolver pattern to bridge JavaScript configuration to native iOS and Android implementations. Most apps never touch it — the SDK ships default Resolvers that cover the standard configuration values. When you need to plug in a custom URLSession, KeyStore, HttpURLConnection provider, or Storage backend, you implement a Resolver in native Swift / Kotlin code and register it with NativeRegistry.shared before initialising the accessor.
This page explains how the bridge actually moves data across, which Resolvers ship by default, when you need a custom one, and how to write and register it.
How Configuration Crosses the Bridge#
JavaScript configuration records carry primitive, JSON-serialisable values — strings, numbers, booleans, and plain objects. They cannot carry runtime instances like URLSession, Bundle, KeyStore, or OkHttpClient. To select platform-specific runtime objects from JS, the SDK uses string enum keys as selectors, and a registry of Resolver instances on the native side that turn those keys into concrete instances.
For example, createIOSConfiguration({ urlSessionOption: URLSessionOption.Default }) ships the string "default" across the bridge. On native init, IdsvrHaapiReactNativeSdkModule calls:
let session = NativeRegistry.shared.urlSession(for: record.iosConfig.urlSessionOption.rawValue)
The registry iterates its registered Resolvers, returns the first non-nil match, and the SDK uses that URLSession for every HTTP call. If no Resolver can resolve the key, the SDK throws HaapiError.invalidConfiguration(reason: "Cannot resolve …") at init.
Why this design. Bridging non-serialisable instances across the React Native bridge is expensive and lossy — you would have to construct opaque handles, retain them across calls, and tear them down on accessor close. The registry pattern keeps the bridge surface narrow (strings + plain records) while letting native code own the heavy lifting. It is also the natural place to plug in TLS pinning, custom proxies, deterministic clocks for tests, and anything else that needs to live in native code.
Default Resolvers#
Each platform pre-registers a small set of Resolvers in NativeRegistry.shared. They cover the standard option values so a typical app needs no native-side work.
iOS#
| Resolver | Resolves keys | Returns |
|---|---|---|
URLSessionResolver | URLSessionOption.Default, .Ephemeral | A standard or ephemeral URLSession |
DefaultBundleResolver | BundleOption.Main | Bundle.main |
The iOS TokenEndpointResponseListener is stored on the registry as a singleton slot (via setTokenEndpointResponseListener / getTokenEndpointResponseListener), not through the Resolver chain. See Token Endpoint Response Listener .
Android#
| Resolver | Resolves keys | Returns |
|---|---|---|
HttpURLConnectionResolver | always returns null — falls through to the SDK’s built-in provider | — |
CurrentTimeMillisResolver | CurrentTimeMillisProviderOption.Default | A () -> System.currentTimeMillis() provider |
ApplicationContextResolver (registered on each initializeForHaapi / initializeForOAuth call) | ApplicationContextOption.Default | The host application’s Context |
StorageResolver (registered on each initializeForHaapi / initializeForOAuth call) | StorageOption.Default | A SharedPreferences-backed RNStorage |
The SDK registers ApplicationContextResolver and StorageResolver through replaceResolver on every init so the registry does not grow unbounded — host-app Resolvers added via addResolver are untouched as long as they are a different concrete class.
When You Need a Custom Resolver#
Each option enum has a Custom value. Selecting Custom is your signal to the SDK that the host app has registered a Resolver in native code. If you select Custom but no Resolver is registered, init fails with invalidConfiguration.
JS option (selecting Custom) | What you supply in native code | Typical use case |
|---|---|---|
URLSessionOption.Custom (iOS) | A custom URLSession | TLS pinning beyond MTLS, traffic interception, custom cookie store, custom protocol classes |
BundleOption.Custom (iOS) | A non-Main Bundle | Loading PKCS12 / PEM resources from a packaged framework rather than the main bundle |
HttpUrlConnectionProviderOption.Custom (Android) | An (URL) -> HttpURLConnection factory | Custom proxy, custom TLS configuration, traffic interception, development overrides |
StorageOption.Custom (Android) | An RNStorage implementation | DataStore-backed storage, EncryptedSharedPreferences with a non-default file name, in-memory storage for tests |
CurrentTimeMillisProviderOption.Custom (Android) | A CurrentTimeMillisProvider | Deterministic clock for tests, NTP-synced clock for stricter DPoP timing |
KeyStoreOption.Client / .Server / .Custom (Android) | A java.security.KeyStore | The actual client / server keystores for MTLS authentication |
ApplicationContextOption.Custom (Android) | A Context | A wrapped Context for testing or framework integration |
The TokenEndpointResponseListener registration uses the same pattern but with dedicated setter / getter methods on the registry — see Token Endpoint Response Listener .
Registering a Custom Resolver#
The Resolver interface is a single-method protocol that returns the resolved instance or null for unrecognised keys. Registration happens once at app startup, before any call to initializeForHaapi or initializeForOAuth.
Implement the Resolver protocol with the concrete Item type the registry expects (URLSession, Bundle, etc.):
import IdsvrHaapiReactNativeSdk
struct PinnedURLSessionResolver: Resolver {
typealias Item = URLSession
func resolve(for key: String) -> URLSession? {
guard let option = URLSessionOption(rawValue: key) else { return nil }
return option == .custom ? Self.makePinnedSession() : nil
}
private static func makePinnedSession() -> URLSession {
let configuration = URLSessionConfiguration.default
// Apply pinning / custom protocol classes / cookie store / etc.
return URLSession(configuration: configuration)
}
}Register from your host app’s Swift code — an Expo Module setup hook, the AppDelegate, or any code that runs before the first initializeForHaapi / initializeForOAuth call:
NativeRegistry.shared.addResolver(PinnedURLSessionResolver())On the JS side, set urlSessionOption: URLSessionOption.Custom in createIOSConfiguration({ … }) so the native init looks up the custom session.
Implement the Resolver<T> interface with the wrapped type the registry expects (RNStorage, RNHttpURLConnectionProvider, Context, CurrentTimeMillisProvider, KeyStore):
package com.example.app
import se.curity.identityserver.haapi.reactnative.sdk.Resolver
import se.curity.identityserver.haapi.reactnative.sdk.configurations.HttpUrlConnectionProviderOption
import se.curity.identityserver.haapi.reactnative.sdk.internal.RNHttpURLConnectionProvider
import java.net.HttpURLConnection
import java.net.URL
class PinnedHttpURLConnectionResolver : Resolver<RNHttpURLConnectionProvider> {
override fun resolve(key: String): RNHttpURLConnectionProvider? =
if (key == HttpUrlConnectionProviderOption.CUSTOM.value) {
RNHttpURLConnectionProvider(provider = ::openPinnedConnection)
} else {
null
}
private fun openPinnedConnection(url: URL): HttpURLConnection {
val connection = url.openConnection() as HttpURLConnection
// Apply TLS pinning, custom socket factory, hostname verifier, etc.
return connection
}
}Register from your host app’s Application.onCreate, before React Native is loaded:
import se.curity.identityserver.haapi.reactnative.sdk.NativeRegistry
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
NativeRegistry.shared.addResolver(PinnedHttpURLConnectionResolver())
// … loadReactNative(this), Expo init, etc.
}
}On the JS side, set httpUrlConnectionProviderOption: HttpUrlConnectionProviderOption.Custom in createAndroidConfiguration({ … }) so the native init looks up the custom provider.
Resolver Priority#
The registry iterates Resolvers first-match-wins. addResolver inserts at the front of the list, so a host-app Resolver registered after the SDK’s defaults takes priority for any key it claims. Resolvers that do not recognise a key return null and the iteration continues to the next Resolver.
There is also replaceResolver (Android) — used by the SDK itself on each init to refresh the per-init Resolvers (ApplicationContextResolver, StorageResolver) without growing the list. Host apps typically use addResolver; replaceResolver is for the case where you want to swap out a previously-registered Resolver of the same concrete class.
Examples#
Custom Android storage backed by EncryptedSharedPreferences#
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import se.curity.identityserver.haapi.reactnative.sdk.Resolver
import se.curity.identityserver.haapi.reactnative.sdk.RNStorage
import se.curity.identityserver.haapi.reactnative.sdk.configurations.StorageOption
class EncryptedStorageResolver(private val context: Context) : Resolver<RNStorage> {
override fun resolve(key: String): RNStorage? =
if (key == StorageOption.CUSTOM.value) {
EncryptedRNStorage(context)
} else {
null
}
}
class EncryptedRNStorage(context: Context) : RNStorage {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context, "haapi_secure_storage", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
// Implement RNStorage methods using prefs.
}
JS side: createAndroidConfiguration({ …, tokenBoundConfiguration: { storageOption: StorageOption.Custom, … } }).
Deterministic clock for tests (Android)#
import se.curity.identityserver.haapi.reactnative.sdk.CurrentTimeMillisProvider
import se.curity.identityserver.haapi.reactnative.sdk.Resolver
import se.curity.identityserver.haapi.reactnative.sdk.configurations.CurrentTimeMillisProviderOption
class FixedClockResolver(private val nowMillis: Long) : Resolver<CurrentTimeMillisProvider> {
override fun resolve(key: String): CurrentTimeMillisProvider? =
if (key == CurrentTimeMillisProviderOption.CUSTOM.value) {
CurrentTimeMillisProvider { nowMillis }
} else {
null
}
}
Useful in JVM-level tests that assert on DPoP iat claims or token expiry calculations.
Pitfalls#
- Forgetting to register before init.
initializeForHaapiresolves dependencies eagerly. Registering a Resolver after the first init call has no effect on accessors already constructed; the registration is in time for the next init. - Selecting
Customwithout a Resolver. The SDK throwsHaapiError.invalidConfigurationwith the offending key in the message — match the key against your Resolver’sresolve(key:)to see why it returnednull. - A Resolver that always returns a value. Resolvers must return
nullfor keys they do not recognise. A Resolver that returns an instance for every key shadows every later Resolver — including the SDK defaults — and breaksDefault/Ephemeral/ etc. selections. - Concurrency. The registry is thread-safe (
NSLockon iOS,@Synchronizedon Android). Your Resolver’sresolvemethod should also be safe to call from any thread — keep it pure and fast; do not perform I/O inside it.