Orchestration SDKs

Handling tokens for multiple clients

Both the Journey module and the DaVinci module support multiple client instances in your apps. Each client instance you create is a runtime object that manages an independent authentication flow, complete with its own configuration, state, and token storage.

This powerful architecture enables a wide range of use cases, including:

  • Multiple concurrent sessions for the same, or different users.

  • Step-up authentication for accessing sensitive resources.

  • Multi-tenant applications with isolated authentication contexts.

  • Parallel token management with different lifecycle policies.

The Journey module and the DaVinci module must not share storage.

The session and OIDC tokens they use aren’t interchangeable.

Understanding the Storage module

By default, all client instances you create share the same storage locations for session tokens and OpenID Connect tokens. This is the simplest configuration and is suitable for many applications.

  • Shared Session/Cookie Storage: All client instances share the same session, representing the same authenticated user.

  • Shared Token Storage: All client instances share the same access token, when using the OIDC module.

This default behavior means that if a user authenticates using one instance, they’re authenticated across all client instances.

Two client instances in an app, sharing session and OIDC token storage.
Figure 1. Two client instances in an app, sharing session and OIDC token storage.

In this scenario, both client instances share the same authenticated user session and access token. A change in one instance, such as signing out, affects the other.

Use Case 1: Step-up authentication (isolating tokens)

A common requirement is to have a single user session but use different access tokens for different levels of security. For example, a user might have a long-lived token for general app usage but an on-demand, short-lived, high-security token for a sensitive transaction.

The Journey and DaVinci modules support this step-up authentication by leveraging multiple client instances, each with different authentication requirements.

To achieve this, you customize the token storage for each instance while continuing to use the shared session storage.

Configuration

In this model:

  • Session Storage remains shared: Both instances recognize the same authenticated user.

  • Token Storage is isolated: Each instance manages its own access token, with its own lifecycle, scopes, and claims.

Two client instances in an app, sharing session tokens but isolating OIDC token storage.
Figure 2. Two client instances in an app, sharing session tokens but isolating OIDC token storage.

With this setup, both client instances share the same user session, but each manages an independent access token. This allows for different permissions and lifecycles for each token.

Code implementation

Here’s how you would configure two instances—one for standard authentication and one for high-security transactions.

  • Android - DaVinci

  • Android - Journey

  • iOS - DaVinci

  • iOS - Journey

Configuring two client instances using two OIDC token storage locations
// Instance 1 - Standard authentication with long-lived tokens
val standardInstance = DaVinci {
    module(Oidc) {
        clientId = "{standard_client_id}"
        scopes = mutableSetOf("openid", "profile", "email")
        redirectUri = "org.forgerock.demo://oauth2redirect"
        discoveryEndpoint = "https://auth.pingone.com/3072206d-c6ce-ch15-m0nd-f87e972c7cc3/as/.well-known/openid-configuration"

        // Custom storage for this instance’s access tokens
        storage {
            fileName = "standard_tokens"
        }
    }

    // No custom session storage is defined, so it uses the default shared session.

}

// Instance 2 - High-security transactions with short-lived tokens
val transactionInstance = DaVinci {
    module(Oidc) {
        clientId = "{transaction_client_id}"
        scopes = mutableSetOf("openid", "transactions")
        redirectUri = "org.forgerock.demo://oauth2redirect"
        discoveryEndpoint = "https://auth.pingone.com/3072206d-c6ce-ch15-m0nd-f87e972c7cc3/as/.well-known/openid-configuration"

        // Separate storage for this instance’s access token
        storage {
            fileName = "transaction_tokens"
        }
    }

    // Also uses the default shared session storage.
}
Configuring two client instances using two OIDC token storage locations
// Instance 1 - Standard authentication with long-lived tokens
val standardInstance = Journey {
    serverUrl = "https://openam-forgerock-sdks.forgeblocks.com/am"
    realm = "alpha"
    module(Oidc) {
        clientId = "{standard_client_id}"
        scopes = mutableSetOf("openid", "profile", "email")
        redirectUri = "org.forgerock.demo://oauth2redirect"

        // Custom storage for this instance’s access tokens
        storage {
            fileName = "standard_tokens"
        }
    }

    // No custom session storage is defined, so it uses the default shared session.

}

// Instance 2 - High-security transactions with short-lived tokens
val transactionInstance = Journey {
    serverUrl = "https://openam-forgerock-sdks.forgeblocks.com/am"
    realm = "alpha"
    module(Oidc) {
        clientId = "transaction-client"
        scopes = mutableSetOf("openid", "transactions")
        redirectUri = "org.forgerock.demo://oauth2redirect"

        // Separate storage for this instance’s access token
        storage {
            fileName = "transaction_tokens"
        }
    }

    // Also uses the default shared session storage.
}
Configuring two client instances using two OIDC token storage locations
// Instance 1 - Standard authentication with long-lived tokens
let standardDaVinciInstance = DaVinci.createDaVinci { config in

    config.module(CookieModule.config) { cookieConfig in
        cookieConfig.cookieStorage = KeychainStorage<[CustomHTTPCookie]>(account: "standard_storage", encryptor: SecuredKeyEncryptor() ?? NoEncryptor())
    }

    config.module(PingDavinci.OidcModule.config) { oidcConfig in
        oidcConfig.clientId = "{standard_client_id}"
        oidcConfig.scopes = ["openid", "profile", "email"]
        oidcConfig.redirectUri = "org.forgerock.demo://oauth2redirect"
        oidcConfig.discoveryEndpoint = "https://auth.pingone.com/3072206d-c6ce-ch15-m0nd-f87e972c7cc3/as/.well-known/openid-configuration"
        oidcConfig.acrValues = "" //update with actual ACR values if needed or

        // Separate storage for this instance’s access token
        oidcConfig.storage = KeychainStorage<Token>(account: "standard_tokens")
    }

}

// Instance 2 - High-security transactions with short-lived tokens
let transactionDaVinciInstance = DaVinci.createDaVinci { config in

    config.module(CookieModule.config) { cookieConfig in
        cookieConfig.cookieStorage = KeychainStorage<[CustomHTTPCookie]>(account: "transaction_cookies", encryptor: SecuredKeyEncryptor() ?? NoEncryptor())
    }

    config.module(PingDavinci.OidcModule.config) { oidcConfig in
        oidcConfig.clientId = "{transaction_client_id}"
        oidcConfig.scopes = ["openid", "transactions"]
        oidcConfig.redirectUri = "org.forgerock.demo://oauth2redirect"
        oidcConfig.discoveryEndpoint = "https://auth.pingone.com/3072206d-c6ce-ch15-m0nd-f87e972c7cc3/as/.well-known/openid-configuration"
        oidcConfig.acrValues = "" //update with actual ACR values if needed or remove

        // Separate storage for this instance’s access token
        oidcConfig.storage = KeychainStorage<Token>(account: "transaction_tokens")
    }

    // Uses the custom cookie storage configured above
}
Configuring two client instances using two OIDC token storage locations
// Instance 1 - Standard authentication with long-lived tokens
let standardInstance = Journey.createJourney { config in
    config.serverUrl = "https://openam-forgerock-sdks.forgeblocks.com/am"
    config.realm = "alpha"
    config.module(PingJourney.OidcModule.config) { oidcConfig in
        oidcConfig.clientId = "{standard_client_id}"
        oidcConfig.scopes = ["openid", "profile", "email"]
        oidcConfig.redirectUri = "org.forgerock.demo://oauth2redirect"

        // Custom storage for this instance’s access tokens
        oidcConfig.storage = KeychainStorage<Token>(account: "standard_tokens")
    }

    // No custom session storage is defined, so it uses the default shared session.
}

// Instance 2 - High-security transactions with short-lived tokens
let transactionInstance = Journey.createJourney { config in
    config.serverUrl = "https://openam-forgerock-sdks.forgeblocks.com/am"
    config.realm = "alpha"
    config.module(PingJourney.OidcModule.config) { oidcConfig in
        oidcConfig.clientId = "{transaction_client_id}"
        oidcConfig.scopes = ["openid", "transactions"]
        oidcConfig.redirectUri = "org.forgerock.demo://oauth2redirect"

        // Separate storage for this instance’s access token
        oidcConfig.storage = KeychainStorage<Token>(account: "transaction_tokens")
    }

    // Also uses the default shared session storage.
}

Server setup for step-up authentication

To enable step-up authentication there must be something within the access token that indicates what level of authentication it can achieve. These are collectively known as authentication indicators.

Authorization Server (AS)

Your authorization server can embed authentication indicators using one or more of the following methods:

amr (Authentication Methods Reference)

Indicates how the user authenticated, for example, pwd, mfa.

Learn about configuring Advanced Identity Cloud to use amr claims at Configure amr claims.

acr (Authentication Context Class Reference)

A standardized OIDC claim indicating the "level" of authentication.

Learn about acr claims in Advanced Identity Cloud at The acr claim.

scopes

The permissions granted by the token, for example, read_transaction.

Configure the OAuth 2.0 client to require elevated authentication for specific scopes:

  • Define scopes that trigger step-up authentication, for example READ_TRANSACTION and WRITE_TRANSACTION.

  • The authorization server enforces step-up when these scopes are requested.

  • Resource servers validate that the token contains the required scope.

auth_level

In Advanced Identity Cloud and PingAM, you can use a custom claim named auth_level that indicates authentication strength.

If an API requires a higher level of authentication, the Resource Server rejects the request, which prompts the app to initiate a step-up flow using the appropriate high-security instance.

Learn about setting auth levels in a journey at Modify Auth Level node.

Example access token containing step-up claims
{
  "sub": "445957f9--...--5452214633e8",
  "cts": "OAUTH2_STATELESS_GRANT",
  "auth_level": 3,
  "auditTrackingId": "c9611769-...-297791",
  "subname": "445957f9-...-5452214633e8",
  "iss": "https://openam-forgerock-sdks.forgeblocks.com/am/oauth2/alpha",
  "tokenName": "access_token",
  "token_type": "Bearer",
  "authGrantId": "fpO7...MaHY",
  "client_id": "demo_user",
  "aud": "demo_user",
  "nbf": 1761261789,
  "grant_type": "authorization_code",
  "scope": [
    "address",
    "phone",
    "openid",
    "profile",
    "READ_TRANSACTION",
    "email"
  ],
  "auth_time": 1761261789,
  "realm": "/alpha",
  "exp": 1761265389,
  "iat": 1761261789,
  "expires_in": 3600,
  "jti": "Ygd5...25lk",
  "amr": [
    "Login2"
  ]
}

Resource Server (RS)

The Resource Server plays a critical role in enforcing access control by validating the claims present in the access token.

Based on the authentication indicators, the Resource Server decides whether to grant or deny access to a protected API, by using one or more of the following techniques:

Token introspection

The Resource Server receives the access token from the client application. It then introspects the token to validate its authenticity and retrieve the associated claims.

Claim validation

The server checks for specific claims to determine if the required authentication level has been met.

  • It might require a minimum auth_level.

  • It might check for the presence of a specific amr value, such as "mfa" or "biometric".

  • It can validate if a required scope is present, for example "READ_TRANSACTION".

Access control

If the claims meet the security policy for the requested API, the server grants access.

If not, it returns an error such as 403 Forbidden or 401 Unauthorized and includes information about the required authentication level.

For example, a resource server protecting a high-value transaction API might enforce the following business rules:

  • Require auth_level to be 3 or higher.

  • Require the amr claim to include biometric.

  • Require scope to contain WRITE_TRANSACTION.

Use Case 2: Multiple users (isolating sessions and tokens)

For applications that need to support multiple, separate user accounts on the same device, you must isolate both session and token storage. This ensures that each user has their own independent authentication context.

Configuration

In this model, each instance has its own dedicated storage for everything.

  • Session Storage is isolated: Each client instance has a separate session file.

  • Token Storage is isolated: Each client instance has a separate token file.

Diagram

In this case, each client instance represents a different authenticated user with completely independent session and OIDC tokens. Signing one user out won’t affect the others.

Code implementation

Here’s how you would configure two instances for two different users, ensuring their sessions and tokens are kept separate.

  • Android - DaVinci

  • Android - Journey

  • iOS - DaVinci

  • iOS - Journey

Configuring two client instances each using independent storage locations
val User_A_instance = DaVinci {

    // Custom session storage for User A
    module(Session) {
        storage {
          fileName = "user_a_sessions"
        }
    }

    module(Oidc) {
        clientId = "{standard_client_id}"
        discoveryEndpoint = "https://auth.pingone.com/3072206d-c6ce-ch15-m0nd-f87e972c7cc3/as/.well-known/openid-configuration"
        scopes = mutableSetOf("openid", "profile", "email")
        redirectUri = "org.forgerock.demo://oauth2redirect"

        // Custom token storage for User A
        storage {
            fileName = "user_a_tokens"
        }
    }
}

val User_B_instance = DaVinci {

    // Custom session storage for User B
    module(Session) {
      storage {
        fileName = "user_b_sessions"
      }
    }

    module(Oidc) {
        clientId = "{standard_client_id}"
        discoveryEndpoint = "https://auth.pingone.com/3072206d-c6ce-ch15-m0nd-f87e972c7cc3/as/.well-known/openid-configuration"
        scopes = mutableSetOf("openid", "profile", "email")
        redirectUri = "org.forgerock.demo://oauth2redirect"

        // Custom token storage for User B
        storage {
            fileName = "user_b_tokens"
        }
    }
}
Configuring two client instances each using independent storage locations
val User_A_instance = Journey {
    serverUrl = "https://openam-forgerock-sdks.forgeblocks.com/am"
    realm = "alpha"

    // Custom session storage for User A
    module(Session) {
        storage {
          fileName = "user_a_sessions"
        }
    }

    module(Oidc) {
        clientId = "{standard_client_id}"
        discoveryEndpoint = "https://openam-forgerock-sdks.forgeblocks.com/am/oauth2/realms/alpha/.well-known/openid-configuration"
        scopes = mutableSetOf("openid", "profile", "email")
        redirectUri = "org.forgerock.demo://oauth2redirect"

        // Custom token storage for User A
        storage {
            fileName = "user_a_tokens"
        }
    }
}

val User_B_instance = Journey {
    serverUrl = "https://openam-forgerock-sdks.forgeblocks.com/am"
    realm = "alpha"

    // Custom session storage for User B
    module(Session) {
      storage {
        fileName = "user_b_sessions"
      }
    }

    module(Oidc) {
        clientId = "{standard_client_id}"
        discoveryEndpoint = "https://openam-forgerock-sdks.forgeblocks.com/am/oauth2/realms/alpha/.well-known/openid-configuration"
        scopes = mutableSetOf("openid", "profile", "email")
        redirectUri = "org.forgerock.demo://oauth2redirect"

        // Custom token storage for User B
        storage {
            fileName = "user_b_tokens"
        }
    }
}
Configuring two client instances each using independent storage locations
let User_A_instance = DaVinci.createDaVinci { config in

    // Custom cookie storage for User A
    config.module(CookieModule.config) { cookieConfig in
        cookieConfig.cookieStorage = KeychainStorage<[CustomHTTPCookie]>(account: "user_a_cookies", encryptor: SecuredKeyEncryptor() ?? NoEncryptor())
    }

    config.module(PingDavinci.OidcModule.config) { oidcConfig in
        oidcConfig.clientId = "{standard_client_id}"
        oidcConfig.scopes = ["openid", "profile", "email"]
        oidcConfig.redirectUri = "org.forgerock.demo://oauth2redirect"
        oidcConfig.discoveryEndpoint = "https://auth.pingone.com/3072206d-c6ce-ch15-m0nd-f87e972c7cc3/as/.well-known/openid-configuration"

        // Custom storage for this instance’s access tokens
        oidcConfig.storage = KeychainStorage<Token>(account: "user_a_tokens")
    }
}

let User_B_instance = DaVinci.createDaVinci { config in

    // Custom cookie storage for User A
    config.module(CookieModule.config) { cookieConfig in
        cookieConfig.cookieStorage = KeychainStorage<[CustomHTTPCookie]>(account: "user_b_cookies", encryptor: SecuredKeyEncryptor() ?? NoEncryptor())
    }

    config.module(PingDavinci.OidcModule.config) { oidcConfig in
        oidcConfig.clientId = "{standard_client_id}"
        oidcConfig.scopes = ["openid", "profile", "email"]
        oidcConfig.redirectUri = "org.forgerock.demo://oauth2redirect"
        oidcConfig.discoveryEndpoint = "https://auth.pingone.com/3072206d-c6ce-ch15-m0nd-f87e972c7cc3/as/.well-known/openid-configuration"

        // Custom storage for this instance’s access tokens
        oidcConfig.storage = KeychainStorage<Token>(account: "user_b_tokens")
    }
}
Configuring two client instances each using independent storage locations
let User_A_instance = Journey.createJourney { config in

    config.serverUrl = "https://openam-forgerock-sdks.forgeblocks.com/am"
    config.realm = "alpha"

    // Custom cookie storage for User A
    config.module(SessionModule.config) { sessionConfig in
        sessionConfig.storage = KeychainStorage<SSOTokenImpl>(
            account: "user_a_sessions",
            encryptor: SecuredKeyEncryptor() ?? NoEncryptor()
        )
    }

    config.module(PingJourney.OidcModule.config) { oidcConfig in
        oidcConfig.clientId = "{standard_client_id}"
        oidcConfig.scopes = ["openid", "profile", "email"]
        oidcConfig.redirectUri = "org.forgerock.demo://oauth2redirect"

        // Custom storage for this instance’s access tokens
        oidcConfig.storage = KeychainStorage<Token>(account: "user_a_tokens")
    }
}

let User_B_instance = Journey.createJourney { config in

    config.serverUrl = "https://openam-forgerock-sdks.forgeblocks.com/am"
    config.realm = "alpha"

    // Custom cookie storage for User B
    config.module(SessionModule.config) { sessionConfig in
        sessionConfig.storage = KeychainStorage<SSOTokenImpl>(
            account: "user_b_sessions",
            encryptor: SecuredKeyEncryptor() ?? NoEncryptor()
        )
    }

    config.module(PingJourney.OidcModule.config) { oidcConfig in
        oidcConfig.clientId = "{standard_client_id}"
        oidcConfig.scopes = ["openid", "profile", "email"]
        oidcConfig.redirectUri = "org.forgerock.demo://oauth2redirect"

        // Custom storage for this instance’s access tokens
        oidcConfig.storage = KeychainStorage<Token>(account: "user_b_tokens")
    }
}

Managing multiple client instances in your app

We designed the SDK for direct and simple management of multiple instances. You don’t need an additional abstraction layer, as each instance is self-contained and provides all the necessary methods to manage its own lifecycle.

Why no abstraction layer?

Simplicity

Adding an abstraction layer introduces unnecessary complexity without significant benefits.

Flexibility

You can implement your own management approach using any data structure, such as a list, map, or set.

Direct Control

Working directly with client instances provides clearer code and better understanding of what’s happening.

No Simplification

An abstraction layer doesn’t simplify the code - it just adds another layer to maintain.

You can manage your instances using standard collections like a Map or a List, or by using direct references.

Example 1: Using direct references for named instances

This approach is easy to read as each operation is specific to a named instance. However it might not scale to support many instances.

  • Android

  • iOS

Using direct references for named instances on Android
class MyApp : Application() {
    // Switch "Journey" to "DaVinci" when using DaVinci
    val standardInstance = Journey { /* config */ }
    val transactionInstance = Journey { /* config */ }

    suspend fun logoutBoth() {
        standardInstance.user()?.logout()
        transactionInstance.user()?.logout()
    }

    suspend fun refreshTransactionToken() {
        transactionInstance.user().refresh()
    }
}
Using direct references for named instances on iOS
import PingJourney

class MyApp {
    // Switch "Journey.createJourney" to "DaVinci.createDaVinci" when using DaVinci
    let standardInstance = Journey.createJourney { /* config */ }
    let transactionInstance = Journey.createJourney { /* config */ }

    func logoutBoth() async {
        let _ = await standardInstance.user()?.logout()
        let _ = await transactionInstance.user()?.logout()
    }

    func refreshTransactionToken() async {
        let _ = await transactionInstance.user()?.refresh()
    }
}

Example 2: Using a Map or Dictionary for named instances

This approach is useful when you have a fixed set of authentication contexts. For example, you might have "standard", "transactions", and "admin" contexts.

  • Android

  • iOS

Using a Map for named instances on Android
class AuthManager {
    // Switch "Journey" to "DaVinci" when using DaVinci
    private val instances = mutableMapOf<String, Journey>()

    init {
        // Switch "Journey" to "DaVinci" when using DaVinci
        instances["standard"] = Journey { /* standard config */ }
        instances["transactions"] = Journey { /* transactions config */ }
        instances["admin"] = Journey { /* admin config */ }
    }

    // Logout all instances
    suspend fun logoutAll() {
        instances.values.forEach { instance ->
            instance.user()?.logout()
        }
    }

    // Refresh a specific token
    suspend fun refreshToken(instanceName: String) {
        instances[instanceName]?.user()?.refresh()
    }

    fun getInstance(name: String) = instances[name]
}
Using a Dictionary for named instances on iOS
import PingJourney

class AuthManager {
    // Switch "Journey" to "DaVinci" when using DaVinci
    private var instances: [String: Journey] = [:]

    init() {
        // Switch "Journey.createJourney" to "DaVinci.createDaVinci" when using DaVinci
        instances["standard"] = Journey.createJourney { /* standard config */ }
        instances["transactions"] = Journey.createJourney { /* transactions config */ }
        instances["admin"] = Journey.createJourney { /* admin config */ }
    }

    // Logout all instances
    func logoutAll() async {
        for instance in instances.values {
            let _ = await instance.user()?.logout()
        }
    }

    // Refresh a specific token
    func refreshToken(instanceName: String) async {
        let _ = await instances[instanceName]?.user()?.refresh()
    }

    func getInstance(name: String) -> Journey? {
        return instances[name]
    }
}

Example 3: Using a List or Array for multiple user accounts

This approach is ideal for scenarios where the number of users is dynamic.

  • Android

  • iOS

Using a List for multiple user accounts on Android
class MultiUserManager {
    // Switch "DaVinci" to "Journey" when using PingOne Advanced Identity Cloud or AM
    private val userInstances = mutableListOf<DaVinci>()

    fun addUser(userId: String): DaVinci {
        val instance = DaVinci {
            // Fully isolated storage configuration
            // Switch "Cookie" to "Session" when when using PingOne Advanced Identity Cloud or AM
            module(Cookie) { storage { fileName = "user_${userId}_session" } }
            module(Oidc) { storage { fileName = "user_${userId}_token" } }
        }
        userInstances.add(instance)
        return instance
    }

    suspend fun logoutAllUsers() {
        userInstances.forEach { it.user()?.logout() }
        userInstances.clear()
    }
}
Using a mutable array for multiple user accounts on iOS
import PingDavinci
import PingStorage

class MultiUserManager {
    // Switch "DaVinci" to "Journey" when using PingOne Advanced Identity Cloud or AM
    private var userInstances: [DaVinci] = []

    func addUser(userId: String) -> DaVinci {
        let instance = DaVinci.createDaVinci { config in
            // Fully isolated storage configuration
            // Switch "CookieModule.self" to "SessionModule.self" when using PingOne Advanced Identity Cloud or AM
            config.module(CookieModule.self) { cookieConfig in
                cookieConfig.storage = KeychainStorage<SSOToken>(account: "user_\(userId)_session")
            }
            config.module(OidcModule.self) { oidcConfig in
                oidcConfig.storage = KeychainStorage<Token>(account: "user_\(userId)_token")
            }
        }
        userInstances.append(instance)
        return instance
    }

    func logoutAllUsers() async {
        for instance in userInstances {
            let _ = await instance.user()?.logout()
        }
        userInstances.removeAll()
    }
}