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.
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.
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
// 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.
}
// 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.
}
// 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
}
// 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
amrclaims at Configure amr claims. acr(Authentication Context Class Reference)-
A standardized OIDC claim indicating the "level" of authentication.
Learn about
acrclaims 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_TRANSACTIONandWRITE_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_levelthat 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.
{
"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
amrvalue, 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 Forbiddenor401 Unauthorizedand 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_levelto be3or higher. -
Require the
amrclaim to includebiometric. -
Require
scopeto containWRITE_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.
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
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"
}
}
}
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"
}
}
}
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")
}
}
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
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()
}
}
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
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]
}
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
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()
}
}
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()
}
}