Token Binding (SDK Layer)#
To bind authorization codes and refresh tokens to a DPoP key pair, configure token binding on HaapiConfiguration. The SDK Layer threads the binding through both the HAAPI flow and the OAuth token exchange automatically. For the concept and security trade-offs, see Token Binding .
Configuration#
let tokenBoundConfiguration = BoundedTokenConfiguration(
keyPairType: CryptoKeyType.secureEnclave
)
let haapiConfiguration = HaapiConfiguration(
// ...other configuration
tokenBoundConfiguration: tokenBoundConfiguration
)keyPairType — where the DPoP key pair lives:
CryptoKeyType.secureEnclave— hardware-backed. The key is generated and held inside the Secure Enclave; the private key never leaves the enclave and cannot be extracted even with root access. Preferred when available.CryptoKeyType.p256— software-backed P256 key pair stored in the Keychain. Use as a fallback when the Secure Enclave is unavailable: App Extensions cannot access the enclave, some device states temporarily lock it, and simulators have no enclave. Less hardware-resistant but more reliable across edge cases.
BoundedTokenConfiguration vs UnboundedTokenConfiguration. The framework exposes both an explicit “bind tokens” and an explicit “don’t bind tokens” configuration. Omit tokenBoundConfiguration to default to unbounded; pass UnboundedTokenConfiguration() to be explicit. The explicit form makes intent visible in code review and prevents accidental binding when the server changes its setting.
Recovering from key-creation failures. If Secure Enclave rejects key generation (rare; documented as HaapiError.dpopKeyCreationFailure and dpopProofFailure), the recommended recovery is to reconstruct the configuration with keyPairType: .p256 and re-authenticate. See Error Handling (SDK Layer) .
val tokenBoundConfiguration = TokenBoundConfiguration(
keyAlias = "token-bound-key",
keyPairAlgorithmConfig = KeyPairAlgorithmConfig.ES256,
storage = mySharedPrefsStorage,
currentTimeMillisProvider = { System.currentTimeMillis() }
)
val haapiConfiguration = HaapiConfiguration(
// ...other configuration
tokenBoundConfiguration = tokenBoundConfiguration
)keyAlias identifies the bound key pair in the Android Keystore. Choose a stable alias scoped to the key’s logical purpose (for example, "token-bound-key" for the OAuth refresh-token binding), not per-session — the binding lifetime extends across many flows.
keyPairAlgorithmConfig selects the signing algorithm. ES256 (ECDSA on P256) is the recommended default — supported on every Android device that exposes the Keystore, and the algorithm DPoP-aware servers typically expect.
storage is mandatory. Supply a Storage implementation (read / write / delete / getAll) that persists key metadata across processes. The Android Keystore alone is not sufficient: under certain conditions (OS upgrades, biometric resets, app-data clears) the Keystore can lose access to keys it generated, and the framework needs this side-table to detect that drift and recover gracefully. SharedPreferences, EncryptedSharedPreferences, or a custom-keystore-backed store all work.
currentTimeMillisProvider lets the framework compute DPoP iat claims with a clock you control. In production, pass { System.currentTimeMillis() }; in tests, inject a deterministic clock to verify time-sensitive behavior.
Opting out. Android does not ship an explicit UnboundedTokenConfiguration type — omit tokenBoundConfiguration (or set it to null) to disable binding.
Token binding is configured per platform as a record attached to createIOSConfiguration / createAndroidConfiguration. The native bridge generates and manages the DPoP key pair internally — your JS code does not handle keys, Keychain entries, or Keystore aliases directly.
import {
createIOSConfiguration,
createAndroidConfiguration,
BoundTokenConfiguration,
CryptoKeyType,
KeyPairAlgorithmConfig,
StorageOption,
CurrentTimeMillisProviderOption,
} from 'identityserver.haapi.reactnative.sdk'
const iosConfig = createIOSConfiguration({
clientId: 'haapi-ios-client',
appRedirect: 'app://haapi',
tokenBoundConfiguration: BoundTokenConfiguration({
keyPairType: CryptoKeyType.SecureEnclave, // or CryptoKeyType.P256 fallback
}),
})
const androidConfig = createAndroidConfiguration({
clientId: 'haapi-android-client',
appRedirect: 'app://haapi',
tokenBoundConfiguration: {
keyAlias: 'token-bound-key',
keyPairAlgorithmConfig: KeyPairAlgorithmConfig.ES256,
storageOption: StorageOption.EncryptedSharedPreferences,
currentTimeMillisProviderOption: CurrentTimeMillisProviderOption.System,
},
})iOS — BoundTokenConfiguration carries the keyPairType (SecureEnclave or P256). Use UnboundedTokenConfiguration() to be explicit about disabling binding, or omit the field. The DPoP key is generated and held in the native Secure Enclave / Keychain — never crosses the bridge.
Android — AndroidTokenBoundConfiguration mirrors the native record but resolves the storage and currentTimeMillisProvider implementations through StorageOption and CurrentTimeMillisProviderOption enums. The SDK ships a SharedPreferences-backed Storage for StorageOption.Default and a System.currentTimeMillis() clock for CurrentTimeMillisProviderOption.Default. For any other value (e.g., EncryptedSharedPreferences, DataStore, a deterministic test clock), the host app must register a Resolver<RNStorage> / Resolver<CurrentTimeMillisProvider> in Kotlin — see Native Resolvers . Omit the field to disable binding entirely.
Opting out. Omit tokenBoundConfiguration on either platform config to default to unbounded.
Choosing a Configuration#
The client binding configuration must match the server’s issue-token-bound-authorization-code setting. Mismatches produce specific errors:
Server issue-token-bound-authorization-code | Client configuration | Result |
|---|---|---|
true | Bound (BoundedTokenConfiguration / TokenBoundConfiguration) | ✅ Refresh token bound to the DPoP key |
true | No binding | ❌ Server returns invalid_dpop_proof on token exchange |
false | Bound | ✅ Refresh token bound on the client side anyway |
false | No binding | ✅ Refresh token not bound |
When binding is enabled on either side, the OAuth token exchange must include the active DPoP proof — see “Exchanging the Code” below.
Exchanging the Code#
When binding is enabled, the OAuth token exchange must include the DPoP proof. On iOS this is explicit: pass haapiManager.dpop into OAuthTokenManager.fetchAccessToken. On Android the SDK threads the DPoP material through automatically when TokenBoundConfiguration is set on the configuration.
// Provide haapiManager.dpop when binding is enabled.
let dPoP = haapiManager.dpop
oAuthTokenManager.fetchAccessToken(with: code, dpop: dPoP) { [weak self] tokenResponse in
self?.handleTokenResponse(tokenResponse)
} // On Android, token binding is configured via the HaapiConfiguration's
// TokenBoundConfiguration; the SDK threads the DPoP material through
// fetchAccessToken automatically when binding is enabled.
val tokenResponse = oAuthTokenManager.fetchAccessToken(
authorizationCode = code,
onCoroutineContext = this.coroutineContext,
additionalParameters = emptyMap()
) // On React Native, token binding is configured via the platform config records
// (IOSTokenBoundConfiguration / AndroidTokenBoundConfiguration). The native side
// threads the DPoP material through automatically; the JS call signature is the
// same whether binding is enabled or not.
const tokenResponse = await accessor.oauthTokenManager.fetchAccessToken(code)The DPoP key never crosses the bridge — neither do nonces, proofs, or HTTP-level binding details. Enable binding by attaching the configuration record (see “Configuration” above) and the native iOS / Android layer handles the rest.
For the full OAuth lifecycle (fetch, refresh, revoke), see OAuthTokenManager .
Omitting the DPoP proof on the token exchange when binding is enabled yields a server error invalid_dpop_proof. iOS callers must pass haapiManager.dpop explicitly; Android does this automatically when TokenBoundConfiguration is configured.
How to implement this: Token Binding (concept) · How to Configure Token Binding · Token Binding (Driver Layer)