How to Migrate an Android App to React Native#

You want the users of an existing native Android app that binds tokens with DPoP to stay signed in when you replace it with a React Native app.

The native SDK keeps a record for each sign-in in its token-binding store: which DPoP key the sign-in is bound to and, for a software key, the key itself. The React Native SDK finds an existing sign-in’s key through those records, so it needs the same store, or a complete copy of it, and the same key settings. Without them it can’t find the key, the server refuses the next refresh, and the user signs in again.

This page maps the native configuration to the React Native one, then shows the two ways to handle the store: keep it (recommended), or move it into the React Native SDK’s encrypted default storage.

Prerequisites#

  • The React Native app is an update of the existing app: the same application ID, signed with the same key. Keystore keys belong to the installed app, so a new install, or a different application ID or signing key, has no access to them.
  • The React Native app keeps the existing app’s client and server configuration.
  • The app has an Android Application class (MainApplication) where native code runs before React Native loads.

This guide carries over the SDK’s token-binding state. The app’s own copy of its tokens, such as the refresh token, belongs to the app, and the app carries it over itself.

Step-by-Step#

1. Map the native configuration#

Set the same key settings in AndroidTokenBinding():

Native app configuredReact Native tokenBindingConfiguration
DpopTokenBindingConfiguration.BuilderAndroidTokenBinding({ keyType, keyPairAlgorithmConfiguration, migrateFromLegacyAlias }) with the same values as the builder’s keyType, keyPairAlgorithmConfig and migrateFromLegacyAlias (leave out the ones the app left at their defaults)
TokenBoundConfiguration (deprecated)AndroidTokenBinding({ migrateFromLegacyAlias: '<its keyAlias>', keyPairAlgorithmConfiguration }), with its keyAlias and the same algorithm. The alias finds the keys of sign-ins made with native SDK versions before 5.7.0, which kept no record of them

In both cases, set keyStoreAlias in createAndroidConfiguration() to the native app’s HaapiConfiguration.keyStoreAlias. The SDK names, rotates and cleans up its keys under that alias, and the defaults differ: the native SDK’s is haapi-entry-alias, the React Native SDK’s default_keystore_alias.

keyType governs only the keys the SDK creates from now on; an existing key keeps the protection it was created with.

2. Find where the native app keeps its token-binding state#

Native app’s storeReact Native storageOptionNext
StorageBuilder.encrypted(context) without a prefix — the builder’s store when it sets neither storage nor prefixStorageOption.Default (the default)Nothing to move. This is the same store the React Native SDK uses by default. Go to step 5
Any other store: the app’s own Storage, StorageBuilder.shared(context), or a builder with a prefix (its store is StorageBuilder.encrypted(context, prefix) unless the app set storage)StorageOption.CustomKeep the store (step 3, recommended), or move it into the default storage (step 4)

The React Native SDK has no prefix setting. A sign-in made with a prefix is found through its record, so the prefix isn’t needed; keys created from now on are named without it.

Don’t switch stores without moving the values. At each sign-in flow, the SDK deletes the device keys under its keyStoreAlias that the store in use has no record of. Pointing the SDK at a different store — StorageOption.Default instead of the app’s own store, or the reverse — therefore ends existing sign-ins and deletes their keys. Keep the store (step 3), or move it first (step 4).

Hand the SDK the native app’s store through a Resolver. Nothing is copied, and existing sign-ins carry on as they are.

If the store implements the native SDK’s Storage interface — your own, or one from StorageBuilder — the app code needs the native SDK. Declare it in the app’s android/app/build.gradle, at the version the React Native SDK bundles (haapi.android.version in the React Native SDK’s package.json):

dependencies {
    implementation "se.curity.identityserver:identityserver.haapi.android.sdk:5.7.0"
}

Then wrap the store as an RNStorage:

import se.curity.identityserver.haapi.android.driver.Storage
import se.curity.identityserver.haapi.reactnative.sdk.RNStorage

class NativeStorageAdapter(private val storage: Storage) : RNStorage {
    override fun delete(key: String) = storage.delete(key)
    override fun get(key: String): String? = storage.get(key)
    override fun getAll(): Map<String, String> = storage.getAll()
    override fun set(value: String, key: String) = storage.set(value, key)
}

Return it for StorageOption.Custom, and register the Resolver in MainApplication.onCreate(), before React Native loads:

import se.curity.identityserver.haapi.reactnative.sdk.RNStorage
import se.curity.identityserver.haapi.reactnative.sdk.Resolver
import se.curity.identityserver.haapi.reactnative.sdk.configurations.StorageOption

class ExistingStoreResolver(
    private val store: RNStorage,
) : Resolver<RNStorage> {
    override fun resolve(key: String): RNStorage? = if (key == StorageOption.CUSTOM.value) store else null
}
import se.curity.identityserver.haapi.android.driver.StorageBuilder
import se.curity.identityserver.haapi.reactnative.sdk.NativeRegistry

class MainApplication : Application(), ReactApplication {
    override fun onCreate() {
        super.onCreate()
        // The native app's store: here a builder's with prefix = "acme". Wrap your own Storage the same way.
        val existing = NativeStorageAdapter(StorageBuilder.encrypted(this, "acme"))
        NativeRegistry.shared.addResolver(ExistingStoreResolver(existing))
        // … loadReactNative(this), Expo init, and so on.
    }
}

Then configure the binding from step 1 with StorageOption.Custom:

import {
    AndroidTokenBinding,
    createAndroidConfiguration,
    StorageOption,
} from '@curity/identityserver-haapi-react-native-sdk'

const androidConfig = createAndroidConfiguration({
    // …the rest of your configuration, unchanged from the native app
    keyStoreAlias: 'haapi-entry-alias', // the native app's keyStoreAlias
    tokenBindingConfiguration: AndroidTokenBinding({
        storageOption: StorageOption.Custom, // the Resolver hands the SDK the native app's store
    }),
})

The store must keep encrypting and integrity-protecting its values at rest: they include serialized key material. The StorageBuilder stores do.

4. Or move the store into the default storage#

If you want the React Native SDK’s encrypted default storage instead, move the existing store’s values into it with the code below. It runs from MainApplication.onCreate() on every start, before React Native loads — in the app’s main process only, once per process — and switches the SDK to the default storage only once a copy is safely in place. Until then, the app keeps using its existing store.

The move uses the native SDK’s StorageBuilder, so declare the native SDK as in step 3. Add the move:

import android.app.ActivityManager
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.os.Process
import android.util.Log
import se.curity.identityserver.haapi.android.driver.StorageBuilder
import se.curity.identityserver.haapi.reactnative.sdk.RNStorage
import java.util.UUID

/**
 * Moves the app's token-binding store into the SDK's default storage without ending a sign-in.
 *
 * Until the switch, every launch copies the whole existing store into the default storage, removes
 * the copies of entries the existing store no longer holds, and leaves a marker there. The first
 * launch that finds an earlier launch's marker knows the default storage survives a restart; it
 * copies again and switches. That launch's copy is taken before the SDK starts, so it holds every
 * key and sign-in record the SDK needs. The existing store is never written to.
 */
object TokenBindingStorageMigration {
    private const val STATE_FILE = "token_binding_migration"
    private const val COPIED_NAMES = "copiedNames"
    private const val CANARY_VALUE = "canaryValue"
    private const val SWITCHED = "switched"

    /** The marker's name in the default storage: outside the SDK's own (`HAAPI_KP_*`, `haapi-dpop-*`). */
    private const val CANARY = "token_binding_migration_canary"

    /** The process the move last ran in. */
    @Volatile private var ranInProcess: Int? = null

    /**
     * Call from `Application.onCreate()`, before React Native loads. Every process of the app runs it, but
     * the move runs only in the main process, and once: a copy taken in another process would miss what the
     * main process does after it.
     */
    fun run(
        context: Context,
        previous: RNStorage,
    ) {
        val pid = Process.myPid()
        if (ranInProcess == pid || !isMainProcess(context, pid)) return
        ranInProcess = pid
        move(previous, { NativeStorageAdapter(StorageBuilder.encrypted(context)) }, state(context))
    }

    /** For the app's `Resolver<RNStorage>`: whether the SDK uses its default storage. */
    fun useDefaultStorage(context: Context): Boolean = state(context).getBoolean(SWITCHED, false)

    /** The move, over the existing store, the default storage and the move's own state. */
    internal fun move(
        previous: RNStorage,
        openDefaultStorage: () -> RNStorage,
        state: SharedPreferences,
    ) {
        if (state.getBoolean(SWITCHED, false)) return
        try {
            val storage = openDefaultStorage()
            // Read before this launch writes anything: only a marker an earlier process left proves a restart.
            val canary = state.getString(CANARY_VALUE, null)
            // A marker that can't be read back counts as missing, and preparing again replaces it.
            val marker =
                try {
                    storage.get(CANARY)
                } catch (e: Exception) {
                    null
                }
            val survivedRestart = canary != null && marker == canary
            val entries = previous.getAll()
            // On record before the copy, so that the copies of a copy cut short are cleaned up as well.
            val copied = state.getStringSet(COPIED_NAMES, null).orEmpty() + entries.keys
            if (!state.edit().putStringSet(COPIED_NAMES, copied).commit()) return
            entries.forEach { (name, value) -> storage.set(value, name) }
            // Copies of entries the existing store has since dropped, such as a rotated refresh token's
            // records. Only names this move copied are ever deleted.
            (copied - entries.keys).forEach(storage::delete)
            if (survivedRestart) {
                // The names it copied include the digests of the sign-ins' tokens: switching drops them, and
                // the marker with them.
                if (state.edit().putBoolean(SWITCHED, true).remove(COPIED_NAMES).remove(CANARY_VALUE).commit()) {
                    storage.delete(CANARY)
                } else {
                    // Android keeps a write that failed to reach the disk in memory, and its next write saves
                    // memory whole. Undo all of it, so that this process stays on the existing store, as the
                    // next one will, and the next launch still knows what was copied.
                    state
                        .edit()
                        .remove(SWITCHED)
                        .putStringSet(COPIED_NAMES, copied)
                        .putString(CANARY_VALUE, canary)
                        .apply()
                }
            } else {
                val newMarker = UUID.randomUUID().toString()
                storage.set(newMarker, CANARY)
                // Only this launch's copies are left, so the record stays as small as the existing store.
                state
                    .edit()
                    .putStringSet(COPIED_NAMES, entries.keys)
                    .putString(CANARY_VALUE, newMarker)
                    .commit()
            }
        } catch (e: Exception) {
            // The default storage can't be opened, or a store failed: the app keeps the existing store,
            // and the next launch tries again. Only the failure's class is logged: a store's exception can
            // quote what the store holds.
            Log.w(
                "TokenBindingStorageMigration",
                "Storage move postponed to the next launch (${e.javaClass.simpleName})",
            )
        }
    }

    private fun isMainProcess(
        context: Context,
        pid: Int,
    ): Boolean {
        val name =
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                Application.getProcessName()
            } else {
                (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager)
                    .runningAppProcesses
                    ?.firstOrNull { it.pid == pid }
                    ?.processName
            }
        return name == context.applicationInfo.processName
    }

    private fun state(context: Context): SharedPreferences = context.getSharedPreferences(STATE_FILE, Context.MODE_PRIVATE)
}

Add the NativeStorageAdapter from step 3, then a Resolver that hands the SDK the existing store until the move has switched, and the SDK’s own default storage after:

import android.content.Context
import se.curity.identityserver.haapi.reactnative.sdk.NativeRegistry
import se.curity.identityserver.haapi.reactnative.sdk.RNStorage
import se.curity.identityserver.haapi.reactnative.sdk.Resolver
import se.curity.identityserver.haapi.reactnative.sdk.configurations.StorageOption

class MigratingStorageResolver(
    private val context: Context,
    private val previous: RNStorage,
) : Resolver<RNStorage> {
    override fun resolve(key: String): RNStorage? =
        when {
            key != StorageOption.CUSTOM.value -> null
            TokenBindingStorageMigration.useDefaultStorage(context) ->
                NativeRegistry.shared.storage(StorageOption.DEFAULT.value)
            else -> previous
        }
}

Run the move as the first step of onCreate() and register the Resolver:

import se.curity.identityserver.haapi.android.driver.StorageBuilder
import se.curity.identityserver.haapi.reactnative.sdk.NativeRegistry

class MainApplication : Application(), ReactApplication {
    override fun onCreate() {
        super.onCreate()
        // The native app's store: here a builder's with prefix = "acme". Wrap your own Storage the same way.
        val previous = NativeStorageAdapter(StorageBuilder.encrypted(this, "acme"))
        TokenBindingStorageMigration.run(this, previous)
        NativeRegistry.shared.addResolver(MigratingStorageResolver(this, previous))
        // … loadReactNative(this), Expo init, and so on.
    }
}

Configure the binding exactly as in step 3, with StorageOption.Custom: the Resolver picks the store. After the switch, the SDK uses its default storage just as with StorageOption.Default, including the errors in step 5.

What the move does:

  • Never writes to the existing store. It only reads it, so until the switch the app runs exactly as before, and the way back stays open.
  • Never writes unencrypted. Copies go only into StorageBuilder.encrypted(context), the store behind the SDK’s default storage. If the Keystore can’t open it, nothing is written and the app keeps its existing store.
  • Switches once the default storage has survived a restart. Each start before the switch copies the whole existing store and leaves a random marker in the default storage. A write reports no disk failure, and a read in the same process is answered from memory, so only a later start that reads the marker back proves the copy is on disk and readable. That start switches.
  • Switches on the second start, whatever the app does. The proof doesn’t depend on the copied values, so an app that starts a sign-in flow or refreshes a token at every launch still switches on its second start.
  • Copies the latest state when it switches. The switching start copies the existing store again before the SDK starts, so sign-ins and refreshes since the first copy are included.
  • Cleans up only its own copies. At every start, it deletes the copies of values the existing store no longer holds, such as the records of a refresh token that has since been replaced, and never a value it didn’t copy. Its own state file holds only the names of the entries it last copied, never values, and drops them and the marker once it has switched.
  • Runs only in the main process. Every process of the app runs onCreate(), but a copy taken in another process, such as a widget’s or a service’s, would miss what the main process does after it. The move runs only in the main process, and once in it.
  • Fails safe. If a store fails part-way, that start keeps the existing store and logs a warning. If the move can’t save its own state, the switch included, that start keeps the existing store as well. Either way, the next start tries again. A marker that can’t be read back, for example after the Keystore key was replaced, counts as missing: that start prepares again.

Keep the configuration steady while the move is in the app. The binding settings and keyStoreAlias from step 1 stay the same before and after the switch, and the configuration keeps StorageOption.Custom. Ship the move while the app still uses its existing store, never after switching stores some other way.

Removing the move later. A later release can drop the move and the Resolver and set StorageOption.Default. Users whose app hasn’t switched yet — the switch needs a second start of a release with the move — then sign in again, so keep the move for as long as users may still upgrade from the native app. The move leaves the existing store as it was; once every user has switched, a later release can delete it.

5. Handle the storage errors#

The default storage has one error of its own, HaapiErrorCode.StorageUnavailable (category haapi, recovery.kind === 'retryable', condition whenAppInForeground). On Android, initializeForHaapi or initializeForOAuth rejects with it when the Keystore is temporarily unavailable and the stored values can only be read through it. The values are kept: retry when the app is back in the foreground and the existing sign-in is kept.

Each failed init counts. After three consecutive failed attempts, counted across app starts, the SDK starts without the stored values, so a device whose Keystore stays broken never blocks the app, and the user signs in again. Retry once per return to the foreground, never in a loop:

import { AppState } from 'react-native'
import {
    initializeForHaapi,
    isHaapiError,
    HaapiErrorCode,
} from '@curity/identityserver-haapi-react-native-sdk'
import type { HaapiAccessor, Logger, RNHaapiConfiguration } from '@curity/identityserver-haapi-react-native-sdk'

async function initializeWhenStorageIsAvailable(
    configuration: RNHaapiConfiguration,
    logger: Logger,
): Promise<HaapiAccessor> {
    try {
        return await initializeForHaapi(configuration, logger)
    } catch (e) {
        if (!isHaapiError(e) || e.code !== HaapiErrorCode.StorageUnavailable) throw e
        // Show "Can't reach secure storage", then retry the next time the app is in the foreground.
        await nextReturnToForeground()
        return initializeWhenStorageIsAvailable(configuration, logger)
    }
}

function nextReturnToForeground(): Promise<void> {
    return new Promise((resolve) => {
        let wasInBackground = AppState.currentState !== 'active'
        const subscription = AppState.addEventListener('change', (state) => {
            if (state !== 'active') {
                wasInBackground = true
            } else if (wasInBackground) {
                subscription.remove()
                resolve()
            }
        })
    })
}

A failed init leaves no accessor behind, so the retry needs no close(). For how the default storage behaves, see Default storage and migration.

Verify It Works#

  1. Install the React Native build over the native app. Don’t uninstall first.
  2. Open the app: the user is still signed in, and a token refresh succeeds without a new sign-in.
  3. If you move the store (step 4), close and reopen the app. On a debuggable build, adb shell run-as <applicationId> cat shared_prefs/token_binding_migration.xml shows switched set to true.
  4. Close and reopen the app once more: a token refresh still succeeds without a new sign-in.

Pitfalls#

  • A missing migrateFromLegacyAlias for a TokenBoundConfiguration app. It raises no error: the SDK doesn’t find the key of a sign-in made before native SDK 5.7.0, creates a new one, and the user signs in again (step 1).
  • A different keyStoreAlias. Sign-ins keep working through their records, but the SDK no longer manages the native app’s keys and never cleans them up. Copy it from the native app (step 1).
  • Switching stores without moving the values. The SDK ends existing sign-ins and deletes their keys at the next sign-in flow (step 2).
  • Setting StorageOption.Default too early. With StorageOption.Default, the SDK ignores the Resolver and uses its default storage right away, before the move has switched. Keep StorageOption.Custom while the move is in the app.
  • Running the move after the SDK starts. The move must finish before React Native loads, so the SDK never opens a store while the move is writing to it. Call it first in MainApplication.onCreate(), never on a background thread.
  • Retrying in a loop. Each failed init counts toward the three attempts. Retrying immediately uses them up in milliseconds, and the user signs in again. Retry on the next return to the foreground.
  • Clearing app data to fix StorageUnavailable. Clearing data deletes the stored keys and signs the user out. The error is temporary: retry instead.
  • Backups. Stored values can only be decrypted on the device that wrote them. Exclude haapi-dpop-encrypted-store.xml, the move’s token_binding_migration.xml and the existing store, if it is backed up, from Auto Backup and device-to-device transfer.

Was this helpful?