Token Lifecycle (UI Layer)#

The flow hands your app an OAuth token model and is then finished with it. The framework does not hold your tokens, refresh them in the background, or revoke them at logout — that is the app’s job, for the whole life of the session.

OAuthLifecycle is the UI-Layer object for that work. It builds the OAuth accessor from the configuration you already assembled for the flow, so refreshing or revoking a token never requires dropping to the SDK Layer and wiring an accessor by hand. Both platforms expose it statically — there is nothing to instantiate.

OperationiOSAndroid
RefreshOAuthLifecycle.refreshToken(_:haapiUIKitApplication:additionalParameters:)OAuthLifecycle.refreshAccessToken(refreshToken:widgetConfiguration:onCoroutineContext:additionalParameters:)
Revoke the access tokenOAuthLifecycle.revokeAccessToken(_:haapiUIKitConfiguration:)OAuthLifecycle.revokeAccessToken(accessToken:widgetConfiguration:onCoroutineContext:)
Revoke the refresh tokenOAuthLifecycle.revokeRefreshToken(_:haapiUIKitConfiguration:)OAuthLifecycle.revokeRefreshToken(refreshToken:widgetConfiguration:onCoroutineContext:)
Read the bound DPoP keyOAuthLifecycle.dpop(forAccessToken:haapiUIKitConfiguration:)OAuthLifecycle.dpopHelper(widgetConfiguration:accessToken:onCoroutineContext:)

Two differences are worth noting before you start. iOS calls its refresh method refreshToken while Android calls it refreshAccessToken. And on iOS, refreshing takes the HaapiUIKitApplication while revoking takes the HaapiUIKitConfiguration; Android takes the WidgetConfiguration throughout.

Refreshing the Access Token#

Refresh returns a model, not a thrown error, when the server rejects the refresh token — an expired or already-used refresh token arrives as an error model, not as an exception. Treat that case as “the session is over, re-authenticate”, not as a transport failure.

do {
    let model = try await OAuthLifecycle.refreshToken(
        refreshToken,
        haapiUIKitApplication: UIApplication.shared.haapiUIKitApplication
    )
    switch model {
    case let token as OAuthTokenModel:
        // Persist the new pair — the previous refresh token is now spent.
        TokenStore.shared.save(token)
    case let error as OAuthErrorModel:
        // e.g. invalid_grant — the refresh token is no longer usable.
        startReauthentication(reason: error.errorDescription)
    default:
        break
    }
} catch {
    // Building the accessor failed (DCR or configuration) — a transport failure
    // during the refresh itself arrives as an OAuthErrorModel above.
    presentAlert("Could not refresh the token: \(error)")
}

additionalParameters: takes a [String: String] appended to the request body when the client needs extra token-endpoint parameters.

lifecycleScope.launch {
    val widgetConfiguration = (application as HaapiUIWidgetApplication).widgetConfiguration
    try {
        when (
            val model = OAuthLifecycle.refreshAccessToken(
                refreshToken = refreshToken,
                widgetConfiguration = widgetConfiguration
            )
        ) {
            // Persist the new pair — the previous refresh token is now spent.
            is OauthModel.Token -> TokenStore.save(model)
            // e.g. invalid_grant — the refresh token is no longer usable.
            is OauthModel.Error -> startReauthentication(model.errorDescription)
        }
    } catch (cancellation: CancellationException) {
        throw cancellation
    } catch (exception: Exception) {
        // Transport failure, or the accessor could not be built from the configuration.
        showAlert("Could not refresh the token: $exception")
    }
}

onCoroutineContext defaults to Dispatchers.IO, so the request leaves the main thread even when the coroutine is launched from lifecycleScope. additionalParameters takes a Map<String, String> appended to the request body.

Revoking a Token#

Revocation reports failure by throwing, on both platforms — there is no model to inspect. A successful call returns nothing.

Revoke the access token to invalidate the credential the app currently holds while keeping the session alive; the refresh token stays usable. Revoke the refresh token to end the session at logout. To do both, revoke the access token first and the refresh token last.

func logout() async {
    do {
        try await OAuthLifecycle.revokeAccessToken(
            accessToken,
            haapiUIKitConfiguration: UIApplication.shared.haapiUIKitApplication.haapiUIKitConfiguration
        )
        try await OAuthLifecycle.revokeRefreshToken(
            refreshToken,
            haapiUIKitConfiguration: UIApplication.shared.haapiUIKitApplication.haapiUIKitConfiguration
        )
        // Only clear once the server has revoked — see the warning below.
        TokenStore.shared.clear()
    } catch {
        // Transport failure, an OAuth error from the server, or no revocation endpoint configured.
        presentAlert("Could not revoke the token: \(error)")
    }
}
private suspend fun logout() {
    val widgetConfiguration = (application as HaapiUIWidgetApplication).widgetConfiguration
    try {
        OAuthLifecycle.revokeAccessToken(
            accessToken = accessToken,
            widgetConfiguration = widgetConfiguration
        )
        OAuthLifecycle.revokeRefreshToken(
            refreshToken = refreshToken,
            widgetConfiguration = widgetConfiguration
        )
        // Only clear once the server has revoked — see the warning below.
        TokenStore.clear()
    } catch (cancellation: CancellationException) {
        throw cancellation
    } catch (exception: Exception) {
        // Transport failure, an OAuth error from the server, or no revocation endpoint configured.
        showAlert("Could not revoke the token: $exception")
    }
}

Clearing your local copy of a token is not revocation. Until the token is revoked at the server, it remains valid for the rest of its lifetime — anyone holding it can still use it. Log out by revoking, then clearing — and clear only once the revocation succeeded. Discarding a token after a failed revocation leaves it live at the server with no copy left to retry with. Retrying is safe: a revocation request for an already-revoked or unknown token still returns 200 (RFC 7009, section 2.2).

Configuring the Revocation Endpoint#

Revocation needs an endpoint that the flow itself never uses, so it is not one of the required configuration parameters. Set it on the same builder you already use for the flow (see Configuration); revoking without it throws.

var haapiUIKitConfiguration = HaapiUIKitConfigurationBuilder(
    clientId: Constants.clientId,
    baseUrl: Constants.baseURL,
    tokenEndpointUrl: Constants.tokenEndpointURL,
    authorizationEndpointUrl: Constants.authorizationEndpointURL,
    appRedirect: Constants.appRedirectURIString
)
.setRevocationEndpointUrl(endpoint: Constants.revocationEndpointURL)
.build()
override val widgetConfiguration: WidgetConfiguration
    get() = WidgetConfiguration.Builder(
        clientId = "my-client-id",
        baseUri = baseUri,
        tokenEndpointUri = baseUri.resolve("/oauth/v2/oauth-token"),
        authorizationEndpointUri = baseUri.resolve("/oauth/v2/oauth-authorize"),
        appRedirect = "app://haapi"
    ).setRevocationEndpointUri(baseUri.resolve("/oauth/v2/oauth-revoke"))
        .build()

The endpoint is typically <baseUrl>/oauth/v2/oauth-revoke. The OAuth client must have revocation enabled in the Curity Identity Server profile.

DPoP for Resource-Server Calls#

When token binding is enabled, the access token the flow produced is DPoP-bound: your own API accepts it only alongside a proof signed by the key it is bound to. OAuthLifecycle hands you that key, so a UI-Layer integration can call its resource server without dropping to the SDK Layer.

Both platforms read the key from secure storage rather than from an object still held in memory, so a proof can be signed after an app relaunch.

guard let dpop = try await OAuthLifecycle.dpop(
    forAccessToken: accessToken,
    haapiUIKitConfiguration: UIApplication.shared.haapiUIKitApplication.haapiUIKitConfiguration
) else {
    return // no DPoP is bound to that token
}

var request = URLRequest(url: apiURL)
request.addAuthorizationHeader(headerValue: "DPoP \(accessToken)")
request.addDpopHeader(headerValue: try dpop.getHeaderValue(httpMethod: "GET",
                                                           url: apiURL,
                                                           nonce: resourceServerNonce,
                                                           accessToken: accessToken))
private suspend fun callApi(accessToken: String, apiUri: URI) {
    val widgetConfiguration = (application as HaapiUIWidgetApplication).widgetConfiguration
    val dpopHelper = OAuthLifecycle.dpopHelper(
        widgetConfiguration = widgetConfiguration,
        accessToken = accessToken
    ) ?: return // no key for this token — not token-bound, or already replaced by a refresh

    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)
    }
}

onCoroutineContext defaults to Dispatchers.IO, so the Keystore read leaves the main thread.

An empty result is a read miss on both platforms: no key is bound to that token, because the client is not token-bound or the token was superseded by a refresh or revoked. Storage that could not be read throws on both platforms instead, so a failure you might retry is never mistaken for a key that is simply absent. OAuthTokenManager sets out each platform’s contract in full.

Both platforms key the lookup by access token. Each asks for the key a given access token is bound to and 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 dpopHelper took no token and returned the client’s current key whichever access token you held. Do not read a non-empty result as evidence that the token is still live at the server — check the token’s own validity instead.

The nonce the framework tracks belongs to the authorization server. Your resource server runs its own, independent nonce space: read DPoP-Nonce from its 401 response, keep the latest value per server, and pass it as the proof’s nonce. Never pass one where the other belongs.

Every call here builds a fresh accessor to reach the manager underneath, so this does not belong in a request loop. If you sign a proof on every API call, build an accessor once and use OAuthTokenManager directly. Either way, sign a fresh proof per request: each carries its own iat and jti, so a reused proof is a replay that a correctly configured server should reject.

  • Flow Lifecycle — the flow that issues the tokens this page manages.
  • Configuration — the builder that carries the revocation endpoint.
  • OAuthTokenManager — the SDK-Layer surface OAuthLifecycle delegates to, with the full token-response field reference and the resource-server nonce pattern.
  • Token Binding — what a DPoP-bound token is and when to enable it.

Was this helpful?