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#

ResolverResolves keysReturns
URLSessionResolverURLSessionOption.Default, .EphemeralA standard or ephemeral URLSession
DefaultBundleResolverBundleOption.MainBundle.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#

ResolverResolves keysReturns
HttpURLConnectionResolveralways returns null — falls through to the SDK’s built-in provider
CurrentTimeMillisResolverCurrentTimeMillisProviderOption.DefaultA () -> System.currentTimeMillis() provider
ApplicationContextResolver (registered on each initializeForHaapi / initializeForOAuth call)ApplicationContextOption.DefaultThe host application’s Context
StorageResolver (registered on each initializeForHaapi / initializeForOAuth call)StorageOption.DefaultA 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 codeTypical use case
URLSessionOption.Custom (iOS)A custom URLSessionTLS pinning beyond MTLS, traffic interception, custom cookie store, custom protocol classes
BundleOption.Custom (iOS)A non-Main BundleLoading PKCS12 / PEM resources from a packaged framework rather than the main bundle
HttpUrlConnectionProviderOption.Custom (Android)An (URL) -> HttpURLConnection factoryCustom proxy, custom TLS configuration, traffic interception, development overrides
StorageOption.Custom (Android)An RNStorage implementationDataStore-backed storage, EncryptedSharedPreferences with a non-default file name, in-memory storage for tests
CurrentTimeMillisProviderOption.Custom (Android)A CurrentTimeMillisProviderDeterministic clock for tests, NTP-synced clock for stricter DPoP timing
KeyStoreOption.Client / .Server / .Custom (Android)A java.security.KeyStoreThe actual client / server keystores for MTLS authentication
ApplicationContextOption.Custom (Android)A ContextA 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.

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. initializeForHaapi resolves 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 Custom without a Resolver. The SDK throws HaapiError.invalidConfiguration with the offending key in the message — match the key against your Resolver’s resolve(key:) to see why it returned null.
  • A Resolver that always returns a value. Resolvers must return null for keys they do not recognise. A Resolver that returns an instance for every key shadows every later Resolver — including the SDK defaults — and breaks Default / Ephemeral / etc. selections.
  • Concurrency. The registry is thread-safe (NSLock on iOS, @Synchronized on Android). Your Resolver’s resolve method should also be safe to call from any thread — keep it pure and fast; do not perform I/O inside it.

Was this helpful?