Authenticator Migration Module
May 5, 2026 · View on GitHub
Authenticator Migration Module
The Authenticator Migration module provides automatic migration capabilities for upgrading legacy FR Authenticator data to the new unified OATH and Push storage format. This module ensures seamless transitions between SDK versions while preserving user accounts, TOTP/HOTP credentials, and push credentials.
Getting Started
Prerequisites
- Ping Advanced Identity Cloud / PingAM Supported Versions
- Android API level 29 or higher
Installation
To integrate this module into your Android project, include the following dependency in
your build.gradle.kts (or build.gradle) file:
dependencies {
implementation("com.pingidentity.sdks:auth-migration:<version>")
}
Replace <version> with the latest available version of the SDK from the Maven repository. Ensure your
project's repositories block includes Maven Central or the Ping Identity Maven repository.
Requirements
To migrate to the new SDK, you must also declare the dependencies on the new modules.
- Dependencies:
mfa:oath- OATH credential storagemfa:push- Push credential and notification storagefoundation:migration- Migration framework
Migration Overview
The Authenticator Migration module automatically migrates legacy FR Authenticator data during application startup. It handles the following migration scenarios:
- OATH Credentials: Migrates TOTP and HOTP mechanisms from legacy format to new SQLOathStorage
- Push Credentials: Migrates Push authentication mechanisms to new SQLPushStorage
- Cleanup: Removes legacy storage files after successful migration via the
StorageClientAPI
What Gets Migrated
| Legacy Component | Target Component | Description |
|---|---|---|
| Legacy Accounts | OATH/Push Credentials | Account metadata (issuer, account name, images) |
| Legacy TOTP/HOTP Mechanisms | SQLOathStorage | TOTP/HOTP credentials with secrets and configuration |
| Legacy Push Mechanisms | SQLPushStorage | Push credentials with endpoints and shared secrets |
Automatic Migration
The migration can be triggered automatically during application startup by declaring AuthenticationMigrationInitializer in your manifest. The initializer launches AuthMigration.start() in a background coroutine — no further manual intervention is required.
To start automatic migration, please add the following code to your manifest file
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false">
<meta-data
android:name="com.pingidentity.auth.migration.AuthenticationMigrationInitializer"
android:value="androidx.startup" />
</provider>
Configuration
Create a LegacyAuthenticationConfig and start the migration, typically in your Application.onCreate() or an AppInitializer:
lifecycleScope.launch {
AuthMigration.start(context)
}
The migration will automatically:
- Check for legacy FR Authenticator data during app startup via
StorageClient - Read and export data from legacy encrypted SharedPreferences (decryption handled by
StorageClientinternally) - Migrate OATH credentials and Push credentials
- Clean up legacy storage via the
StorageClientAPI after successful migration - Skip migration if no legacy data exists
LegacyAuthenticationConfig
LegacyAuthenticationConfig is configured via a DSL block passed to AuthMigration.start(). No arguments are required for standard FR Authenticator installations — the empty block (or no block at all) uses sensible defaults.
| Property | Default | Description |
|---|---|---|
legacyStorageProvider | StorageClientProvider | Override to supply data from a custom storage backend |
logger | Logger.STANDARD | Logger instance used throughout the migration pipeline |
backup | {} (no-op) | Callback forwarded to LegacyStorageProvider.cleanUp and invoked before legacy data is cleared. Pass a real implementation to persist data before deletion |
// Default — no configuration needed
lifecycleScope.launch {
AuthMigration.start(applicationContext)
}
// Custom storage provider
lifecycleScope.launch {
AuthMigration.start(applicationContext) {
legacyStorageProvider = MyCustomStorageProvider(applicationContext)
}
}
// Custom logger
lifecycleScope.launch {
AuthMigration.start(applicationContext) {
logger = Logger.WARN
}
}
// Backup before cleanup
lifecycleScope.launch {
AuthMigration.start(applicationContext) {
backup = { ctx -> MyBackupHelper.backup(ctx) }
}
}
Migration Steps
The migration process consists of three sequential steps:
Step 1: Import Legacy Data
- Calls
LegacyStorageProvider.isMigrationRequired()— aborts early if no migration is needed - If migration is required, calls
LegacyStorageProvider.getMigrationData()to loadLegacyExportedData - Default implementation (
StorageClientProvider) reads accounts and mechanisms from the legacyStorageClientand serialises them using the ForgeRock SDK's owntoJson()methods - Aborts migration if the mechanisms list is empty
Step 2: Migrate OATH and Push Mechanisms
- Database Preparation: Before migrating, checks for existing OATH and Push SQLite databases:
- If a database exists and contains credentials, it is preserved (migration skips re-creating it)
- If a database exists but is empty, it is deleted to avoid passphrase conflicts
- If a database cannot be opened (e.g., encrypted with a different passphrase), it is deleted and recreated
- Initialises
SQLOathStorageandSQLPushStoragewithallowDestructiveRecovery = trueandbackupOnError = truefor resilience during migration - Parses mechanism data to determine type (TOTP/HOTP/Push)
- Creates
OathCredentialobjects for TOTP/HOTP mechanisms:- Preserves secret, algorithm (SHA1/SHA256/SHA512), digits, period
- Maintains issuer, account name, and visual customizations
- Creates
PushCredentialobjects for Push mechanisms:- Preserves server endpoint, shared secret (query parameters stripped from endpoint URL)
- Maintains account information and visual customizations
- Stores credentials in
SQLOathStorageandSQLPushStoragerespectively
Step 3: Cleanup Legacy Data
- Calls
LegacyStorageProvider.cleanUp(context, backup) - The
backupcallback fromLegacyAuthenticationConfigis always invoked first — pass a no-op (the default) to skip backup, or a real implementation to persist data before it is cleared - Closes both
SQLOathStorageandSQLPushStoragedatabase connections - Cleanup failures don't abort the migration (non-critical)
Manual Migration
You can trigger the migration manually at any point. AuthMigration.start() suspends until the pipeline is fully complete (or cleanly aborted). Pipeline errors are logged via the configured logger and do not propagate as exceptions to the caller:
// Default — uses the standard ForgeRock key alias
lifecycleScope.launch {
AuthMigration.start(applicationContext)
// Migration complete (or cleanly aborted) when start() returns
}
// Custom Storage implementation
lifecycleScope.launch {
AuthMigration.start(applicationContext) {
legacyStorageProvider = MyCustomStorageProvider(applicationContext)
}
}
Monitoring Migration Progress
Pass a collector before calling start(), or observe through the migration's own logging. The start() function internally collects and logs every MigrationProgress event:
lifecycleScope.launch {
AuthMigration.start(context)
// Migration is complete when start() returns
}
AuthMigration logs the following events automatically via its logger:
Migration started
Migration in progress: Step 1 of 3 - Import legacy data
Step completed: Import legacy data
Migration in progress: Step 2 of 3 - Migrate mechanisms to OATH and Push storage
Step completed: Migrate mechanisms to OATH and Push storage
Migration in progress: Step 3 of 3 - Cleanup legacy authenticator data
Step completed: Cleanup legacy authenticator data
Migration completed successfully
To observe raw MigrationProgress events, use the Migration API directly with your own steps.
Error Handling
The migration framework includes robust error handling. Pipeline step failures are logged at error level via the configured logger and do not propagate as exceptions — start() always returns normally regardless of whether a step failed.
Migration failed at step 'Import legacy data': <cause message>
To observe failures in your own code, configure a custom logger in the DSL block:
lifecycleScope.launch {
AuthMigration.start(applicationContext) {
logger = MyAppLogger() // receives error events including MigrationProgress.Error
}
}
Individual Mechanism Failures
- Failures in individual mechanism migration don't stop the entire process
- Errors are logged for debugging purposes
- Partial migrations are supported
Database Recovery
Before migration begins, the framework inspects any existing SQLite databases:
- Databases with existing credentials are preserved unchanged
- Empty databases are removed to prevent passphrase conflicts
- Databases that cannot be opened (e.g., encrypted with a different passphrase) are deleted and recreated
- Storage instances are initialised with
allowDestructiveRecovery = trueso that a force-close during migration does not leave the database in an unrecoverable state
Migration Abortion
- Migration automatically aborts if no legacy data is found
- No errors are thrown for clean installations
- Subsequent app starts skip migration checks after successful completion
Custom Storage Migration
If your application stores authenticator data in a custom storage backend (e.g., a different StorageClient implementation, an encrypted database, or a remote store), you can supply that data to the migration pipeline by implementing LegacyStorageProvider.
LegacyStorageProvider Interface
interface LegacyStorageProvider {
/** Return true if migration is needed (i.e., legacy data still exists). */
suspend fun isMigrationRequired(context: Context): Boolean
/**
* Return the legacy authenticator data as [LegacyExportedData].
* The returned object is fed directly into the migration pipeline.
*/
suspend fun getMigrationData(context: Context): LegacyExportedData
/**
* Remove legacy data after successful migration.
* Invoke [backup] before clearing to allow the caller to persist data.
* Pass a no-op (the default) to skip backup.
*/
suspend fun cleanUp(context: Context, backup: (context: Context) -> Unit = {})
}
Option 1: Using a ForgeRock StorageClient
If your custom storage implements the ForgeRock StorageClient interface, use the built-in LegacyDataConverter.convertToLegacyExportedData() helper. It calls account.toJson() and mechanism.toJson() on each entry via the SDK's own serialisation — no manual field mapping required:
class MyStorageProvider(
private val context: Context,
private val storageClient: StorageClient
) : LegacyStorageProvider {
override suspend fun isMigrationRequired(context: Context): Boolean =
!storageClient.isEmpty
override suspend fun getMigrationData(context: Context): LegacyExportedData =
withContext(Dispatchers.IO) {
LegacyDataConverter.convertToLegacyExportedData(storageClient)
}
override suspend fun cleanUp(context: Context, backup: (context: Context) -> Unit) {
backup(context) // invoke before clearing to allow caller to persist data
// Remove data from your custom storage
storageClient.clear()
}
}
Option 2: Building from raw JSON strings
If your storage exposes per-entry JSON strings (e.g., the same format ForgeRock uses internally), use LegacyDataConverter.buildMechanismsList():
override suspend fun getMigrationData(context: Context): LegacyExportedData =
withContext(Dispatchers.IO) {
val mechanismsMap = mutableMapOf<String, String>()
val accountsMap = mutableMapOf<String, String>()
myStorage.getAllMechanisms().forEach { mechanismsMap[it.id] = it.toJson() }
myStorage.getAllAccounts().forEach { accountsMap[it.id] = it.toJson() }
val mechanisms = LegacyDataConverter.buildMechanismsList(mechanismsMap, accountsMap)
LegacyExportedData(
mechanisms = mechanisms,
metadata = LegacyExportMetadata(totalMechanisms = mechanisms.size)
)
}
Option 3: Building LegacyExportedData manually
For fully custom storage backends, construct LegacyExportedData directly:
override suspend fun getMigrationData(context: Context): LegacyExportedData =
withContext(Dispatchers.IO) {
val mechanisms = myStorage.getAllMechanisms().map { m ->
LegacyMechanism(
id = m.id,
issuer = m.issuer,
accountName = m.accountName,
mechanismUID = m.uid,
secret = m.secret,
type = m.type, // "otpauth" or "pushauth"
oathType = m.oathType, // "TOTP" or "HOTP" (OATH only)
algorithm = m.algorithm, // "SHA1", "SHA256", "SHA512" (OATH only)
digits = m.digits,
period = m.period,
counter = m.counter,
registrationEndpoint = m.registrationEndpoint, // Push only
authenticationEndpoint = m.authenticationEndpoint, // Push only
account = LegacyAccount(
id = m.accountId,
issuer = m.issuer,
accountName = m.accountName,
imageURL = m.imageURL,
backgroundColor = m.backgroundColor
)
)
}
LegacyExportedData(
mechanisms = mechanisms,
metadata = LegacyExportMetadata(totalMechanisms = mechanisms.size)
)
}
Registering a Custom Storage Provider
Pass your implementation via the DSL block in AuthMigration.start():
lifecycleScope.launch {
AuthMigration.start(applicationContext) {
legacyStorageProvider = MyStorageProvider(
context = applicationContext,
storageClient = MyStorageClient()
)
}
}
Expected Data Format
The LegacyExportedData your implementation returns must conform to the structure in sample_export.json. Key points:
- Each mechanism contains its associated account nested directly (
accountfield — 1-to-1 mapping) - OATH mechanisms use
type: "otpauth", Push mechanisms usetype: "pushauth" metadata.exportedAtis a UTC epoch millisecond timestamp
{
"mechanisms": [
{
"id": "Forgerock-totp@forgerock.com-otpauth",
"issuer": "Forgerock",
"accountName": "totp@forgerock.com",
"mechanismUID": "e238a4ce-23f4-4f41-9ccd-2ef85fc641d9",
"secret": "JBSWY3DPEHPK3PXP",
"type": "otpauth",
"oathType": "TOTP",
"algorithm": "SHA256",
"digits": 6,
"period": 60,
"timeAdded": 1772565130356,
"account": {
"id": "Forgerock-totp@forgerock.com",
"issuer": "Forgerock",
"displayIssuer": "Forgerock Display",
"accountName": "totp@forgerock.com",
"displayAccountName": "TOTP Display User",
"imageURL": "http://forgerock.com/logo.jpg",
"backgroundColor": "#032b75",
"lock": false
}
},
{
"id": "PingIdentity-user@example.com-pushauth",
"issuer": "PingIdentity",
"accountName": "user@example.com",
"mechanismUID": "22752db7-8d03-4181-9f06-f440e46527c1",
"secret": "BASE64_ENCODED_SECRET",
"type": "pushauth",
"registrationEndpoint": "https://example.com/am/push/sns/message?_action=register",
"authenticationEndpoint": "https://example.com/am/push/sns/message?_action=authenticate",
"platform": "PING_AM",
"uid": "user123",
"resourceId": "resource-id-123",
"timeAdded": 1772565130356,
"account": {
"id": "PingIdentity-user@example.com",
"issuer": "PingIdentity",
"displayIssuer": "Ping Identity",
"accountName": "user@example.com",
"displayAccountName": "Demo User",
"imageURL": "https://pingidentity.com/logo.png",
"backgroundColor": "#0066cc",
"lock": false
}
}
],
"metadata": {
"totalMechanisms": 2,
"exportedAt": 1772565130356
}
}
See sample_export.json for a complete three-mechanism example.
Data Mapping
OATH Credentials
| Legacy Field | New Field | Notes |
|---|---|---|
issuer | issuer | Preserved exactly |
accountName | accountName | Preserved exactly |
secret | secret | Base32-encoded shared secret |
algorithm | oathAlgorithm | Maps SHA1/SHA256/SHA512 |
digits | digits | Typically 6 or 8 |
period | period | For TOTP (default 30s) |
counter | counter | For HOTP |
oathType | oathType | Maps to TOTP or HOTP enum |
imageURL | imageURL | Optional logo URL |
backgroundColor | backgroundColor | Optional UI customization |
Push Credentials
| Legacy Field | New Field | Notes |
|---|---|---|
issuer | issuer | Preserved exactly |
accountName | accountName | Preserved exactly |
authenticationEndpoint | serverEndpoint | Server URL for push (query params stripped) |
secret | sharedSecret | Cryptographic secret |
imageURL | imageURL | Optional logo URL |
backgroundColor | backgroundColor | Optional UI customization |
Troubleshooting
Common Issues
Migration Not Running
- Ensure
AuthMigration.start(context)is called on app startup - Check that the
auth-migrationdependency is included in your build
Legacy Data Not Found
- This is normal for new installations or users who never used FR Authenticator
- Migration will abort with
ABORTresult (not an error) - No action required
Storage Initialization Errors
- Verify
SQLOathStorageandSQLPushStorageare properly initialized - Check database permissions and storage availability
- Review SQLite database version compatibility
Existing Database Conflicts
- The migration automatically detects and resolves database passphrase conflicts
- Empty databases are deleted and recreated; databases with existing data are preserved
- If a database cannot be opened, it is deleted so migration can proceed with a fresh database
Debug Logging
Enable debug logging to monitor migration progress:
// Migration uses the standard Ping Logger
Logger.logger = Logger.STANDARD
Check logs for migration status:
Ping SDK: Checking for legacy authenticator data
Ping SDK: Legacy repository exists, proceeding with migration
Ping SDK: Successfully exported 3 mechanisms
Ping SDK: Checking existing databases before migration
Ping SDK: Starting mechanisms migration to new storage
Ping SDK: Migrated OATH credential: ForgeRock - user@example.com
Ping SDK: Migrated Push credential: PingAM - admin@company.com
Ping SDK: Migrated 3 OATH credentials and 2 Push credentials
Ping SDK: Cleaning up legacy authenticator data
Ping SDK: Successfully cleaned up legacy authenticator data
Ping SDK: Migration completed successfully
Migration State
The migration maintains state between steps:
- Exported data is stored in migration context
- Storage instances are initialized once and reused across steps
- Failed migrations can be retried on next app start
- Successful migrations are marked complete to prevent re-running
Migration Safety
The migration process is designed to be safe and non-destructive:
- Backup Approach: The
backupcallback inLegacyAuthenticationConfigis forwarded toLegacyStorageProvider.cleanUpand invoked before data is cleared.StorageClientProvidercalls it unconditionally — pass a no-op (the default) to skip backup. CustomLegacyStorageProviderimplementations receive the same callback viacleanUp(context, backup)and are responsible for invoking it before clearing their storage. - Database Safety: Before migrating, existing OATH and Push databases are inspected. Databases containing credentials are preserved; empty or incompatible databases are removed to ensure a clean migration.
- Rollback Safety: Original data remains until migration succeeds
- Idempotent: Can be run multiple times safely (only runs once per install)
- Partial Recovery: Individual mechanism failures don't affect others
- Cleanup Safety: Legacy data is only deleted after successful migration
Data Integrity
- All cryptographic secrets maintain their security properties
- User authentication credentials are preserved exactly
- Account associations remain intact
- No data is lost during migration process
- Key management is handled internally by the
StorageClient(legacy) and SQLCipher (new storage)
Performance Considerations
- Migration runs on a background thread (
Dispatchers.IO) - Doesn't block app startup
- Typically completes in 1-5 seconds for normal datasets
- Scales linearly with number of accounts/mechanisms
Example Migration Scenario
Before Migration (Legacy SDK)
Legacy SharedPreferences (read via StorageClient):
├── org.forgerock.android.authenticator.DATA.ACCOUNT
│ ├── "ForgeRock-user@example.com"
│ └── "PingAM-admin@company.com"
└── org.forgerock.android.authenticator.DATA.MECHANISM
├── "ForgeRock-user@example.com-totp"
├── "ForgeRock-user@example.com-push"
└── "PingAM-admin@company.com-totp"
After Migration (New SDK)
SQLOathStorage (SQLite):
├── OathCredential(issuer="ForgeRock", account="user@example.com", type=TOTP)
└── OathCredential(issuer="PingAM", account="admin@company.com", type=TOTP)
SQLPushStorage (SQLite):
└── PushCredential(issuer="ForgeRock", account="user@example.com")
Legacy Files: [Deleted via StorageClient] (backed up if backup callback was provided)
Additional Resources
License
This software may be modified and distributed under the terms of the MIT license. See the LICENSE file for details.
© Copyright 2025-2026 Ping Identity Corporation. All rights reserved.