OAuthTokenManager#
OAuthTokenManager handles the OAuth lifecycle after the HAAPI flow reaches OAuthAuthorizationResponseStep: fetching the first access token, refreshing it before it expires, and revoking the refresh token on logout. Obtain an instance from HaapiAccessor (created via the SDK Layer builders) and call it from the same authentication context that ran the flow.
Fetching the Access Token#
When the HAAPI flow completes, exchange the authorization code for a TokenResponse. Success cases carry an access token, refresh token, and expiry; error cases surface as ErrorTokenResponse.
let code = authResponseStep.oauthAuthorizationResponseProperties.code!
oAuthTokenManager.fetchAccessToken(with: code) { [weak self] tokenResponse in
self?.handleTokenResponse(tokenResponse)
}
private func handleTokenResponse(_ tokenResponse: TokenResponse) {
switch tokenResponse {
case let .successfulToken(success):
let accessToken = success.accessToken
let refreshToken = success.refreshToken
// persist tokens to secure storage
case let .errorToken(errorTokenResponse):
// handle OAuth-protocol error
break
case let .error(anError):
// handle framework or transport error
break
}
}val code = authResponseStep.properties.code
GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val tokenResponse = oAuthTokenManager.fetchAccessToken(
authorizationCode = code,
onCoroutineContext = this.coroutineContext,
additionalParameters = emptyMap()
)
handleTokenResponse(tokenResponse)
}
private fun handleTokenResponse(tokenResponse: TokenResponse) {
when (tokenResponse) {
is SuccessfulTokenResponse -> {
val accessToken = tokenResponse.accessToken
val refreshToken = tokenResponse.refreshToken
// persist tokens to secure storage
}
is ErrorTokenResponse -> {
// handle OAuth-protocol error
}
}
}On React Native, fetchAccessToken returns a Promise. SDK failures (network, attestation) reject the Promise — OAuth-protocol errors come back inside the resolved TokenResponse. Catch both surfaces in the same async block:
import { isHaapiError } from 'identityserver.haapi.reactnative.sdk'
import type { OAuthAuthorizationResponseStep, TokenResponse } from 'identityserver.haapi.reactnative.sdk'
const step = response.representation as OAuthAuthorizationResponseStep
const code = step.oauthAuthorizationResponseProperties.code
try {
const tokenResponse = await accessor.oauthTokenManager.fetchAccessToken(code)
handleTokenResponse(tokenResponse)
} catch (e) {
if (isHaapiError(e)) {
// Network / attestation / DPoP / bridge failure — see Error Handling.
}
}
function handleTokenResponse(tokenResponse: TokenResponse) {
if (tokenResponse.responseType === 'success') {
const accessToken = tokenResponse.accessToken
const refreshToken = tokenResponse.refreshToken
// Persist to a secure store (expo-secure-store, react-native-keychain).
} else {
// tokenResponse.error / errorDescription — RFC 6749 OAuth error
}
}Store accessToken and refreshToken in secure storage — Keychain on iOS, EncryptedSharedPreferences or the Keystore on Android.
Successful Token Response Fields#
The success branch carries six fields. Only accessToken and expiresIn are guaranteed non-null — the rest depend on what the server returned for the configured scopes and grant type.
| Field | iOS (SuccessfulTokenResponse) | Android (SuccessfulTokenResponse) | React Native (SuccessfulTokenResponse) | Notes |
|---|---|---|---|---|
accessToken | String | String | string | Required. The OAuth access token to attach to authenticated requests. |
expiresIn | Int | Int | number | Required. Lifetime of accessToken in seconds, counted from token issuance. |
refreshToken | String? | String? | string? | Present when the client is configured for a refresh-capable grant type. |
tokenType | String? | String? | string? | Typically "DPoP" when binding is enabled, "Bearer" otherwise. |
scope | String? | String? | string? | Space-delimited list of scopes the server actually granted. May differ from what was requested. |
idToken | String? | String? | string? | Present when openid is in the granted scopes. |
UI-Layer integrations receive the same six fields under different type names — OAuthTokenModel on iOS, OauthModel.Token on Android — delivered through the flow-result handler rather than through OAuthTokenManager.fetchAccessToken. The fields and their semantics are identical.
Refreshing the Access Token#
When the access token expires, present the refresh token to obtain a fresh pair:
oAuthTokenManager.refreshAccessToken(with: "refresh_token") { [weak self] tokenResponse in
self?.handleTokenResponse(tokenResponse)
}GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val tokenResponse = oAuthTokenManager.refreshAccessToken(
refreshToken = "refresh_token",
onCoroutineContext = this.coroutineContext
)
handleTokenResponse(tokenResponse)
}const tokenResponse = await accessor.oauthTokenManager.refreshAccessToken('refresh_token')
handleTokenResponse(tokenResponse)Pass optional extra OAuth parameters as a second argument:
await accessor.oauthTokenManager.refreshAccessToken('refresh_token', {
scope: 'openid profile',
})On invalid_grant the refresh token is dead: discard the held tokens and prompt the user to re-authenticate. Re-issuing the same request will not recover it. On Android from 5.7 only that sign-in ends — the user’s other sign-ins under the same manager keep working.
Revoking the Refresh Token#
To log the user out cleanly, revoke the refresh token. The SDK releases the DPoP key that sign-in was using unless another sign-in still depends on it; on Android the manager’s attestation key is never removed by this cleanup:
oAuthTokenManager.revokeRefreshToken("refresh_token") { result in
// handle success or error
}GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
try {
oAuthTokenManager.revokeRefreshToken(
refreshToken = "refresh_token",
onCoroutineContext = this.coroutineContext
)
// success — SDK has deleted any token-bound key pair
} catch (e: OAuthUnrecoverableException) {
// server returned a 4xx with a parsed OAuth error
}
}Two revoke methods, one each for refresh and access tokens. Both return Promise<void> on success and reject with a HaapiError on transport or OAuth-protocol failure:
import { isHaapiError } from 'identityserver.haapi.reactnative.sdk'
try {
await accessor.oauthTokenManager.revokeRefreshToken('refresh_token')
// success — SDK has deleted any token-bound key pair
} catch (e) {
if (isHaapiError(e)) {
// Server returned an error, or transport failed.
}
}
// Access tokens can also be revoked explicitly:
await accessor.oauthTokenManager.revokeAccessToken('access_token')Using a DPoP-Bound Access Token#
Platform-only: iOS and Android. The React Native equivalent is not available yet.
When the authorization server issues a DPoP-bound access token, presenting it to your own resource server takes two headers: the token in Authorization, and a DPoP proof signed by the key that token is bound to. Both SDKs read that key back out of their own secure storage, so the proof can still be signed after an app relaunch, not only while the object that ran the flow is alive.
dpop(forAccessToken:) returns the Dpop for the key that token is bound to, read from the SDK’s credential storage.
// Once — after the flow completes, or after an app relaunch:
guard let dpop = try oAuthTokenManager.dpop(forAccessToken: accessToken) else {
// Not token-bound, or this token was superseded or revoked — see below.
return
}
// Then per request, passing whichever access token is current:
var request = URLRequest(url: apiURL)
request.addAuthorizationHeader(headerValue: "DPoP \(accessToken)")
request.addDpopHeader(headerValue: try dpop.getHeaderValue(httpMethod: "GET",
url: apiURL,
nonce: resourceServerNonce,
accessToken: accessToken))nil means no DPoP is bound to that token: either the client is not token-bound, so nothing was ever stored, or the access token has been superseded by a refresh or removed by a successful revocation. In both cases the token cannot be presented with a proof, and the app’s route forward is a fresh authentication. Passing a refresh token also yields nil — the lookup is keyed for access tokens only, so a mistyped argument fails closed rather than handing back a key and inviting the refresh token to be sent to a resource server.
Upgrading from 5.6.0, an access token the app already held returns nil here until its next successful token response. The lookup deliberately does not read the older storage layout, because that layout cannot distinguish an access token from a refresh token. Refresh, or re-authenticate, and the entry is available again.
A thrown HaapiError.dpopProofFailure means something different and is worth handling separately: the credential storage could not be read, so whether a DPoP exists is unknown. The cause carries the underlying StorageError — StorageError.unavailable identifies a locked keychain or an unusable access group, which is usually transient. Retrying once the device is unlocked is the right response there; re-authenticating the user is not, since it would fail the same way.
The keychain’s default accessibility is afterFirstUnlockThisDeviceOnly, so a background launch before the device’s first unlock after a reboot is the case this distinction exists for. See Error Handling (Driver Layer).
dpopHelper(accessToken) returns the DpopHelper for the DPoP key that access token is bound to, read from the Android Keystore. It is a suspend function because that read is I/O, and it runs on Dispatchers.IO unless you pass another context.
val dpopHelper = oAuthTokenManager.dpopHelper(accessToken) ?: return // no key for this token — see below
val proof = dpopHelper.generateProofToken(
method = "GET",
uri = apiUri,
nonce = resourceServerNonce,
accessToken = accessToken
)
val connection = (apiUri.toURL().openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
setRequestProperty(HttpConstants.Headers.AUTHORIZATION, "DPoP $accessToken")
setRequestProperty(HttpConstants.Headers.DPOP, proof)
}null is a read miss: the client has no DPoP binding configured, or that token is not one it holds a key for — a token from elsewhere, or one already replaced by a refresh. Pass the access token you last received; if that still returns null, there is no proof to present and the route forward is a fresh authentication.
A thrown IdsvrHaapiException means something different and is worth handling separately: the Keystore could not be read, so whether a key exists is unknown. It carries error keystore_exception and the underlying failure as its cause — a key invalidated by a device credential change, for example. The distinction exists so a failure that may succeed on a later attempt is not mistaken for an absent key.
UI Layer integrators reach the same lookup through OAuthLifecycle.dpopHelper(widgetConfiguration, accessToken).
Both platforms key the lookup by access token. Each returns an empty result once that token has been superseded by a refresh or removed by a revocation, so pass the access token you are about to send and re-fetch after a refresh. On Android this holds from 5.7; before then the lookup took no token and returned the client’s current key whichever token you held.
Hold the key, not the manager. Signing needs only the key pair, never the OAuth machinery around it. On iOS the same key signs for a token and for every token that replaces it on refresh, so fetch the Dpop once and reuse it, passing the current access token on each request; keeping an OAuthTokenManager or accessor alive only to re-fetch it retains a URLSession and the rest of a stack that proof signing never touches. Re-fetch after a new authentication, which mints a new key. On Android, ask for the DpopHelper with the access token you are about to send rather than caching one — a Keystore read is cheap beside the request the proof accompanies, and re-fetching after a refresh keeps you on the right key. On both platforms the UI Layer entry point builds a fresh accessor on every call, so it does not belong in a request loop.
The Resource Server’s Nonce Is Yours to Manage#
The nonce the SDK tracks internally belongs to the authorization server. A resource server runs its own, independent nonce space — never pass one where the other belongs.
A resource server that requires a nonce rejects the first request with 401 and a DPoP-Nonce header. Read it, keep the latest value per resource server, and retry once:
Read the header with the Driver Layer’s URLResponse.dpopNonce():
let (data, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse,
http.statusCode == 401,
let freshNonce = response.dpopNonce() {
resourceServerNonce = freshNonce // store it for this server, then retry the request
}The connection to your API is yours, so read the header off it directly — HttpConstants.Headers.DPOP_NONCE holds the name:
if (connection.responseCode == 401) {
connection.getHeaderField(HttpConstants.Headers.DPOP_NONCE)?.let { freshNonce ->
resourceServerNonce = freshNonce // store it for this server, then retry the request
}
}Sign a fresh proof for every request rather than caching one: each carries its own iat and jti, so a reused proof is a replay that a correctly configured server should reject. Both SDKs put the required ath claim — the hash of the access token, per RFC 9449 §4.2 — into every proof you pass an access token to.
Related Topics#
The DPoP-proof requirement when token binding is enabled and the raw-response listener pattern each get their own page:
- Token Binding covers how to pass
haapiManager.dpop(iOS) or rely on the automatic Android threading when binding is on. - Token Endpoint Response Listener covers capturing raw HTTP headers and avoiding double-handling.
How to implement this: iOS SDK · Android SDK · Error Handling (SDK Layer) · Token Endpoint Response Listener · Token Binding · Error Handling