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.DefaultThe default storage — encrypted at rest (see Default storage and migration)

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.

Default storage and migration (Android)#

StorageOption.Default stores the token-binding state — including the software-fallback DPoP key pair used when the device cannot produce a hardware-backed key — encrypted at rest (AES-GCM) under a non-exportable Android Keystore key. There is nothing to configure.

  • Upgrading from an earlier SDK version. Earlier versions kept these values unencrypted. On the first init after the upgrade, every value is moved into the encrypted store and the unencrypted copies are removed, so existing sessions are kept without signing in again. The move is automatic and needs no code change.

  • If the Keystore is unavailable. If the move itself can’t complete, the SDK keeps using the existing values for that run — nothing is lost — and retries on the next init. Once the values live only in the encrypted store, an unavailable Keystore (for example, a locked device) makes initializeForHaapi / initializeForOAuth reject with HaapiErrorCode.StorageUnavailable (retryable): retry when the app is back in the foreground and the existing sign-in is kept. After three consecutive failed attempts the SDK starts without the stored key so the app is never blocked, and the user signs in again. Warnings go through the SDK logger; no stored value is ever logged.

  • Values are bound to the device. A stored value can only be decrypted on the device that wrote it. If an app backup is restored onto another device, the restored values cannot be read there: the SDK treats them as absent and removes them, and the user signs in once. We recommend excluding both storage files — haapi_token_bound_storage.xml and haapi-dpop-encrypted-store.xml — from Auto Backup and device-to-device transfer in your app’s backup rules, so an older unencrypted copy can’t be restored either. Android matches each rule’s path as a single file name, so list them one per <exclude> — in res/xml/backup_rules.xml (Android 11 and lower) and in each section of res/xml/data_extraction_rules.xml (Android 12 and higher):

    <exclude domain="sharedpref" path="haapi_token_bound_storage.xml" />
    <exclude domain="sharedpref" path="haapi-dpop-encrypted-store.xml" />
  • Custom storage is unaffected. With StorageOption.Custom, your RNStorage is used as-is and no migration runs. It must encrypt and integrity-protect values at rest, as the default store does — the values include serialized key material.

  • Moving from another store. Values in a custom store, or in a native Android app’s store, are not migrated automatically. Switching such an app to StorageOption.Default without moving them ends existing sign-ins: the SDK no longer finds their keys, and removes the device keys the store in use doesn’t record at the next sign-in flow. Keep the store, or move its values first — see How to Migrate an Android App to React Native.

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 implementationAn existing app-owned secure store, DataStore-backed storage, in-memory storage for tests
CurrentTimeMillisProviderOption.Custom (Android)A CurrentTimeMillisProviderDeterministic clock for tests, NTP-synced clock for stricter DPoP timing
KeyStoreOption.Client / .Server / .AlternateClient / .AlternateServer (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? {
        // The JS `URLSessionOption.Custom` value.
        key == "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#

The default storage is already encrypted at rest; use a custom store only when your app needs its own backend.

import android.content.Context
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({ …, tokenBindingConfiguration: AndroidTokenBinding({ 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?