//HAAPI Android SDK Documentation
HAAPI Android SDK Documentation
[androidJvm] Android library with classes and functions required to access Curity Identity Server Hypermedia Authentication API (HAAPI) from Android devices. The library allows a client to go through an authorization flow via HAAPI using a set of models that represent the different responses/steps involved.
For details about the representations used in HAAPI please refer to the HAAPI Data Model documentation.
Requirements
- The HAAPI SDK framework works on Android 8.0+ (API level 26+)
Getting started
Setting up HAAPI SDK
In your app/build.gradle, add identityserver.haapi.android.sdk to the dependencies block as demonstrated below.
apply plugin: 'com.android.application'
android { /* ... */ }
dependencies {
implementation("se.curity.identityserver:identityserver.haapi.android.sdk:5.2.0")
// ... other dependencies ...
}
Usage
Configuration
Start by creating a HaapiConfiguration for the client access to the server.
// Configuration parameters
val clientId = "haapi-android-client"
val baseURLString = "https://10.0.2.2:8443"
val intermediatePath = "/oauth/v2"
val baseUri = URI.create(baseURLString)
val tokenEndpointUri = URI.create("$baseURLString$intermediatePath/oauth-token")
val authorizationEndpointUri = URI.create("$baseURLString$intermediatePath/oauth-authorize")
val appRedirect = "app://haapi"
// HaapiConfiguration
val haapiConfiguration = HaapiConfiguration(
keyStoreAlias = "keyStoreAlias",
clientId = clientId,
baseUri = baseUri,
tokenEndpointUri = tokenEndpointUri,
authorizationEndpointUri = authorizationEndpointUri,
appRedirect = appRedirect
)
Instantiate a HaapiAccessor via the HaapiAccessorFactory to access HaapiManager and OAuthTokenManager.
HaapiAccessorFactory allows obtaining the accessors to access HAAPI and OAuth from the current device, based on an initial static configuration and the device's capabilities.
The preferred access strategy is to obtain HAAPI access tokens using client attestation, i.e. the device’s key attestation capabilities. If attestation is not supported, or if the Curity Identity Server deems the attestation data as invalid, an optional fallback strategy based on Dynamic Client Registration can be used.
The DCR-based fallback uses templatized client registration: the client configured in HaapiConfiguration is used to register a dynamic client based on a template ID configured via setDCRConfiguration. The registration happens on the first time the fallback is used for a given template client ID. The resulting client data is stored on the device and considered by HaapiAccessorFactory on subsequent runs.
The HaapiAccessor instances created by this class include:
- A ready-to-use HaapiManager to execute authorization flows.
- A ready-to-use OAuthTokenManager to execute OAuth requests like refreshToken and revoke.
ℹ️ When the DCR-based access is used, both HaapiManager and OAuthTokenManager use credentials different from what’s supplied in the initial configuration.
The recommended way to use HaapiAccessorFactory is to create and configure a single instance and invoke create once before going through an authorization flow via HAAPI.
Note that HaapiManager internally uses a HaapiTokenManager to manage the necessary resources to enforce security and identity when the app communicates with the Curity Identity Server. Please refer to the IdsvrHaapiDriver documentation to know more about the underlying framework.
// Configurations
val haapiConfiguration: HaapiConfiguration = TODO("see Configuration section - example omitted")
// Coroutine exception handler
private val coroutineExceptionHandler =
CoroutineExceptionHandler { _, throwable ->
// Handle the exception
handleThrowable(throwable)
}
GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val haapiAccessor = HaapiAccessorFactory(
haapiConfiguration = myApplication.haapiConfiguration
)
.createForHaapi(onCoroutineContext = this.coroutineContext)
val haapiManager = haapiAccessor.haapiManager
val oAuthTokenManager = haapiAccessor.oAuthTokenManager
}
Configuring HTTPCookie management
To establish and maintain a potentially long-lived session between client and server, HttpURLConnection includes an extensible cookie manager. For more information on setting up cookie management for HttpUrlConnection please refer to Android API docs. The HAAPI SDK framework does not handle this automatically as it should be setup at the application level as per use case requirement. To do so, only a couple of lines of code are required to enable the cookie management behaviour like demonstrated below.
// override default behaviour to accept all cookies
CookieHandler.setDefault(CookieManager(null, CookiePolicy.ACCEPT_ALL))
// keep default behaviour to accept only cookies from original server
val cookieManager = CookieManager()
CookieHandler.setDefault(cookieManager)
Running the Haapi flow (authorization flow)
Invoke HaapiManager.start to start the HAAPI flow that returns a HaapiResponse.
The HaapiResponse can return one of these cases:
HaapiRepresentation: The object has to be cast to the corresponding step as illustrated below. The step's properties have to be presented to the user. Based on the user's action or chosen link,HaapiManager.submitorHaapiManager.followLinkhas to be invoked to move forward.ProblemRepresentation: The object represents a problem that occurred and it requires corrections from the user or the client.ClientOperationStep: The object represents a client operation, which requires the client to trigger an external action like opening a web browser or an application.
⚠️ Exceptions can be thrown such as IdsvrHaapiException and its subtypes HaapiManagerUnsupportedContentTypeException, HaapiManagerUnsupportedHttpMethodException, HttpClientUnrecoverableException and also broader Exception. Read the Error Handling section for further information about this topic.
The HaapiResponse needs to be handled as illustrated below.
GlobalScope.launch(Dispatchers.IO) {
try {
val response = haapiManager.start(coroutineContext)
handleHaapiResponse(response)
} catch (error: Exception) {
when (error) {
is IdsvrHaapiException.Retryable -> scheduleRetry(error)
is IdsvrHaapiException.Unrecoverable -> presentFailure(error)
}
}
}
// Handlers
private fun handleHaapiResponse(haapiResponse: HaapiResponse) {
when (haapiResponse) {
is HaapiRepresentation -> {
handleHaapiRepresentation(haapiResponse)
}
is ClientOperationStep -> {
handleClientOperationStep(haapiResponse)
}
is ProblemRepresentation -> {
handleProblemRepresentation(haapiResponse)
}
}
}
private fun handleHaapiRepresentation(haapiRepresentation: HaapiRepresentation) {
when (haapiRepresentation) {
is AuthenticatorSelectorStep -> { /* handle the AuthenticatorSelectorStep */ }
is PollingStep -> { /* handle the PollingStep */ }
is InteractiveFormStep -> { /* handle the InteractiveFormStep */ }
is UserConsentStep -> { /* handle the UserConsentStep */ }
is GenericRepresentationStep -> { /* handle the GenericRepresentationStep */ }
is RedirectionStep -> { /* handle the RedirectionStep */ }
is ContinueSameStep -> { /* handle the ContinueSameStep */ }
is OAuthAuthorizationResponseStep -> { /* handle the OAuthAuthorizationResponseStep */ }
else -> {
throw IllegalStateException("HaapiRepresentation is not handled.")
}
}
}
private fun handleClientOperationStep(clientOperationStep: ClientOperationStep) {
when (clientOperationStep) {
is ExternalBrowserClientOperationStep -> { /* handle the ExternalBrowserClientOperationStep */ }
is GenericClientOperationStep -> { /* handle the GenericClientOperationStep */ }
is WebAuthnRegistrationClientOperationStep -> { /* handle the WebAuthnRegistrationClientOperationStep */ }
is WebAuthnAuthenticationClientOperationStep -> { /* handle the WebAuthnAuthenticationClientOperationStep */ }
is EncapClientOperationStep -> { /* handle the EncapClientOperationStep */ }
else -> {
throw IllegalStateException("ClientOperationStep is not handled.")
}
}
}
private fun handleProblemRepresentation(problemRepresentation: ProblemRepresentation) {
// Handle the ProblemRepresentation.
}
With a HaapiRepresentation, invoke HaapiManager.submit or HaapiManager.followLink to move forward on the HAAPI flow and handle the new HaapiResult.
private fun submit(formActionModel: FormActionModel, parameters: Map<String, Any>) {
GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val haapiResponse = haapiManager.submitForm(
form = formActionModel,
parameters = parameters,
onCoroutineContext = this.coroutineContext
)
handleHaapiResponse(haapiResponse)
}
}
private fun followLinK(link: Link) {
GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val haapiResponse = haapiManager.followLink(
link = link,
onCoroutineContext = this.coroutineContext
)
handleHaapiResponse(haapiResponse)
}
}
When obtaining an OAuthAuthorizationResponseStep, the HAAPI flow reaches the end. OAuthAuthorizationResponseStep contains a code that is required to get an access_token.
Running the OAuth flow
Invoke OAuthTokenManager.fetchAccessToken with the code in OAuthAuthorizationResponseStep.properties to get a TokenResponse.
When receiving a TokenResponse, the object can be:
- a
SuccessfulTokenResponse: the access_token is present with other properties such as arefresh_tokenif present. - an
ErrorTokenResponse: an error that is returned by the server. Check theerrorand theerrorDescriptionand retry if possible with the missing configuration.
⚠️ Exceptions can be thrown such as IdsvrHaapiException and its subtypes HaapiManagerUnsupportedContentTypeException, and also other broader Exception types. Check the Error Handling section for further information about this topic.
val code = oauthAuthorizationResponseStep.properties.code
GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
try {
val tokenResponse =
oAuthTokenManager.fetchAccessToken(
authorizationCode = response.code,
onCoroutineContext = this.coroutineContext,
additionalParameters = emptyMap()
)
handleTokenResponse(tokenResponse)
} catch (error: Exception) {
when (error) {
is IdsvrHaapiException.Retryable -> scheduleRetry(error)
is IdsvrHaapiException.Unrecoverable -> presentFailure(error)
}
}
}
private fun handleTokenResponse(tokenResponse: TokenResponse) {
return when (tokenResponse) {
is SuccessfulTokenResponse -> {
val accessToken = tokenResponse.accessToken
val refreshToken = tokenResponse.refreshToken
}
is ErrorTokenResponse -> { /* Handle the ErrorTokenResponse */ }
}
}
ℹ️ It is recommended to keep the SuccessfulTokenResponse especially the access_token and the refresh_token in a secured storage.
If the access_token is expired, invoke OAuthTokenManager.refreshAccessToken using the stored refresh_token to get a new TokenResponse.
GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val tokenResponse = oAuthTokenManager.refreshAccessToken(
refreshToken = "your_refresh_token",
onCoroutineContext = this.coroutineContext
)
handleTokenResponse(tokenResponse)
}
ℹ️ Check this for a concrete example.
Additional configurations/usages
Binding authorization code
Issue token-bound authorization code
When using issue-token-bound-authorization-code (true) in the Identity Server configuration, it is mandatory to bind the tokens on the client side.
To bind tokens on the client side, it is required to configure TokenBoundConfiguration to the HaapiConfiguration as demonstrated below.
// It is recommended to provide a secured storage for`TokenBoundConfiguration.storage` such as SharedPreferences. This property is important when the KeyStore is not reliable enough to keep a generated KeyPair.
val securedStorage = object: Storage {
override fun delete(key: String) {
// Implements the deletion
}
override fun get(key: String): String? {
// Implements the getter
}
override fun getAll(): Map<String, String> {
// Implements the getter all
}
override fun set(value: String, key: String) {
// Implements the setter
}
}
val tokenBoundConfiguration = TokenBoundConfiguration(
keyAlias = "token_bound_configuration_key_alias",
keyPairAlgorithmConfig = KeyPairAlgorithmConfig.ES256,
storage = securedStorage,
currentTimeMillisProvider = { System.currentTimeMillis() }
)
// HaapiConfiguration
val haapiConfiguration = HaapiConfiguration(
keyStoreAlias = "keyStoreAlias",
clientId = clientId,
baseUri = baseUri,
tokenEndpointUri = tokenEndpointUri,
authorizationEndpointUri = authorizationEndpointUri,
appRedirect = appRedirect,
tokenBoundConfiguration = tokenBoundConfiguration
)
Issue unbound authorization code
On the other hand, when issue-token-bound-authorization-code is set to false in the Identity Server configuration, binding the tokens is a choice on the client side.
To not bind the token on the client side, TokenBoundConfiguration should not be configured to HaapiConfiguration as demonstrated below:
// HaapiConfiguration
val haapiConfiguration = HaapiConfiguration(
keyStoreAlias = "keyStoreAlias",
clientId = clientId,
baseUri = baseUri,
tokenEndpointUri = tokenEndpointUri,
authorizationEndpointUri = authorizationEndpointUri,
appRedirect = appRedirect,
tokenBoundConfiguration = null
)
Client Authentication Method
Using Attestation to enforce API security should be the default behavior, ClientAuthenticationMethod.None, which should work for most users. For the signing key-based client attestation method to work, there must be hardware support, and this can sometimes be uncertain due to the multitude of different device models and user setup in day to day use.
Non-compliant devices provide their alternative proof via the Client Credentials Flow, in a request for an access token with the dcr scope. There are multiple ways in which the credential can be supplied during this request.
Secret
A simple client secret can be used to request the client credentials, which will need to be the same for all users. This is not a secure option, but it can be useful in some setups, such as when first getting integrated, or as a solution for a development stage of the deployment pipeline.
val secretClientAuthMethod = ClientAuthenticationMethodConfiguration.Secret(secret = "foo")
MTLS
The app can use a client certificate bundled with it or import it from its public key hash representation. Then, it sends the proof of ownership to the Curity Identity Server over a Mutual TLS connection. The Curity Identity Server is configured to verify the trust chain of the client certificate.
// using bundled certificates
val mtls = ClientAuthenticationMethodConfiguration.Mtls(
clientKeyStore = TODO("provide client keystore instance"),
clientKeyStorePassword = "foo",
serverTrustStore = TODO("provide server trust store")
)
// using server public key hash
val mtls2 = ClientAuthenticationMethodConfiguration.MtlsKeyHash(
clientKeyStore = TODO("provide client keystore instance"),
clientKeyStorePassword = "foo",
serverKeyPinnings =
setOf(
ClientAuthenticationMethodConfiguration.MtlsKeyHash.KeyPinning(
hostname = "localhost",
publicKeyHash = "Kjuy4mT3fbeDozRNP6rTjWRYmbs79Begb5Roq+CNwiG="
)
),
isValidatingHostname = true
)
Client Assertion
The app can make use of both symmetric and asymmetric keypairs and load them into the device Keychain, then use it to produce a JWT Client Assertion, which it then sends as proof of ownership. The Curity Identity Server is then configured with a way to get the public key with which to verify received assertions.
// Asymetric
val asym = ClientAuthenticationMethodConfiguration.SignedJwt.Asymmetric(
clientKeyStore = /* omitted for brevity */,
clientKeyStorePassword = "foo",
alias = "foo",
algorithmIdentifier =
ClientAuthenticationMethodConfiguration.SignedJwt.Asymmetric.AlgorithmIdentifier.ES256 // according to your certificate
)
// Symmetric
val sym = ClientAuthenticationMethodConfiguration.SignedJwt.Symmetric(
secretKey = "foo",
signatureAlgorithm =
ClientAuthenticationMethodConfiguration.SignedJwt.Symmetric.SignatureAlgorithm.HS256 // according to the server configuration
)
Dynamic Client Registration (DCR) for devices that do not support attestation
Instances of an app running on non-compliant devices will have to prove their identity based on an alternative client credential and perform a Client Credential Flow, resulting in the creation of a dynamic client. Here you can find more information on how to setup the server environment and prepare the configuration.
Single configuration
The below snippet illustrates configurations for devices that can support or not attestation.
// Configuration parameters
val clientId = "haapi-android-client-secret" // When using DCR, it is required to have a client that supports secret, mtls or jwt.
val clientAuthMethod = ClientAuthenticationMethodConfiguration.Secret(secret = "foo")
val baseURLString = "https://10.0.2.2:8443"
val intermediatePath = "/oauth/v2"
val baseUri = URI.create(baseURLString)
val tokenEndpointUri = URI.create("$baseURLString$intermediatePath/oauth-token")
val authorizationEndpointUri = URI.create("$baseURLString$intermediatePath/oauth-authorize")
val appRedirect = "app://haapi"
// HaapiConfiguration
val haapiConfiguration = HaapiConfiguration(
keyStoreAlias = "keyStoreAlias",
clientId = clientId,
baseUri = baseUri,
tokenEndpointUri = tokenEndpointUri,
authorizationEndpointUri = authorizationEndpointUri,
appRedirect = appRedirect
)
// DCR configuration
val dcrConfiguration = DcrConfiguration(
templateClientId = "dcr-template-client-id",
clientRegistrationEndpointUri = URI.create("$baseURLString$intermediatePath/oauth-token"),
context = this
)
// Create the HaapiAccessorFactory with the DCR configuration to get a HaapiManager/OAuthTokenManager. These objects are correctly configured and are aware if your device supports or not attestation.
GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val haapiAccessor = HaapiAccessorFactory(
haapiConfiguration = haapiConfiguration
)
.setDcrConfiguration(dcrConfiguration)
.setClientAuthenticationMethodConfiguration(clientAuthMethod /* example omitted */)
.create(onCoroutineContext = this.coroutineContext)
val haapiManager = haapiAccessor.haapiManager
val oAuthTokenManager = haapiAccessor.oAuthTokenManager
}
Two configurations
If an application is already using a client configuration that only supports attestation, it is required to have a second client configuration to support non-attestation devices.
The below snippet illustrates this use-case.
// The haapi configuration for attestation only
val attestationHaapiConfiguration = HaapiConfiguration(
keyStoreAlias = "keyStoreAlias",
clientId = "attestation-haapi-client",
baseUri = baseUri,
tokenEndpointUri = tokenEndpointUri,
authorizationEndpointUri = authorizationEndpointUri,
appRedirect = appRedirect
)
// The haapi configuration for non-attestation which requires a client authentication for secret, mtls or jwt
val nonAttestationHaapiConfiguration = HaapiConfiguration(
keyStoreAlias = "keyStoreAlias",
clientId = "non-attestation-haapi-client-using-client-auth-method",
baseUri = baseUri,
tokenEndpointUri = tokenEndpointUri,
authorizationEndpointUri = authorizationEndpointUri,
appRedirect = appRedirect
)
// It can be secret, mtls or jwt
val clientAuthMethod = ClientAuthenticationMethodConfiguration.Secret(secret = "foo")
val dcrConfiguration = DcrConfiguration(
templateClientId = "dcr-template-client-id",
clientRegistrationEndpointUri = URI.create("$baseURLString$intermediatePath/oauth-token"),
context = this
)
// Detecting when to switch configuration
// 1. Using the attestation configuration
GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val haapiAccessor = HaapiAccessorFactory(
haapiConfiguration = attestationHaapiConfiguration
)
.create(onCoroutineContext = this.coroutineContext)
// If no error was triggered, then the device supports attestation and HaapiManager/OAuthTokenManager are available.
// Otherwise, check coroutineExceptionHandler for HaapiError.UnsupportedHaapiException
val haapiManager = haapiAccessor.haapiManager
val oAuthTokenManager = haapiAccessor.oAuthTokenManager
}
private val coroutineExceptionHandler =
CoroutineExceptionHandler { _, throwable ->
when (throwable) {
is HaapiError.UnsupportedHaapiException -> {
// The device does not support attestation
useSecondConfiguration()
}
else -> {
// Handle the exception
handleThrowable(throwable)
}
}
}
private fun useSecondConfiguration() {
GlobalScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val haapiAccessor = HaapiAccessorFactory(
haapiConfiguration = nonAttestationHaapiConfiguration
)
.setDcrConfiguration(dcrConfiguration)
.setClientAuthenticationMethodConfiguration(ClientAuthenticationMethodConfiguration.Secret(""))
.create(onCoroutineContext = this.coroutineContext)
// If the configuration is correct, then HaapiAccessor will use the DCR configuration and HaapiManager/OAuthTokenManager are available.
// Otherwise, check coroutineExceptionHandler to understand the problem.
val haapiManager = haapiAccessor.haapiManager
val oAuthTokenManager = haapiAccessor.oAuthTokenManager
}
}
⚠️ When providing a DcrConfiguration it is required to set the ClientAuthenticationMethodConfiguration to a value different from ClientAuthenticationMethodConfiguration.None otherwise it will fail.
Using risk assessment services
When integrating with services that may require application context information (ex: BankID's risk assessment functionality), it is required to configure the applicationContext in HaapiConfiguration. This allows the framework to collect and manage the necessary information to provide the service with.
⚠️ For reference about the collected information please refer to the official BankID Relying Party Guidelines for version 6, and API documentation.
To ensure optimal functionality in managing the risk assessment information, it is advised to set the android:allowBackup flag in your app’s manifest file (AndroidManifest.xml) to ensure persistent state. This is the default setting when creating a new application project.
Listening to the response from the token endpoint via OAuthTokenManager
When invoking OAuthTokenManager.fetchAccessToken or OAuthTokenManager.refreshAccessToken, responses are handled and returned as TokenResponse. The TokenResponse may be a SuccessfulTokenResponse or ErrorTokenResponse.
SuccessfulTokenResponse and ErrorTokenResponse contain the attributes as defined in RFC 6749.
With these objects, it is not possible to get the headers or the raw data response. If they are needed, a listener (OAuthTokenManager.TokenEndpointResponseListener) has to be configured.
The following snippet illustrates how to configure the listener.
// Create a class that conforms to OAuthTokenManager.TokenEndpointResponseListener
class MyTokenEndpointResponseListener : OAuthTokenManager.TokenEndpointResponseListener {
override fun onSuccess(value: OAuthTokenManager.SuccessTokenResponseContent) {
// SuccessfulTokenResponse
value.successfulTokenResponse.accessToken
value.successfulTokenResponse.refreshToken
// responseAsJsonObject
value.responseAsJsonObject?.getString("access_token")
value.responseAsJsonObject?.getString("refresh_token")
// Others
value.headerFields
value.contentType
}
override fun onTokenError(value: OAuthTokenManager.ErrorTokenResponseContent) {
// ErrorTokenResponse
value.errorTokenResponse.error
value.errorTokenResponse.errorDescription
// bodyAsJsonObject
value.bodyAsJsonObject?.getString("error")
// Others
value.headerFields
value.contentType
}
override fun onError(value: HttpClient.Response.Failure) {
// Throwable
value.throwable
// bodyAsJsonObject
value.bodyAsJsonObject
// Others
value.headerFields
value.contentType
}
}
// Create a HaapiConfiguration with MyTokenEndpointResponseListener
val haapiConfiguration = HaapiConfiguration(
clientId = CLIENT_ID,
baseUri = BASE_URI,
tokenEndpointUri = TOKEN_ENDPOINT_URI,
authorizationEndpointUri = AUTHORIZATION_ENDPOINT_URI,
appRedirect = APP_REDIRECT,
useAttestation = false,
tokenEndpointResponseListener = MyTokenEndpointResponseListener()
)
val haapiAccessor = HaapiAccessorFactory(haapiConfiguration)
.setClientAuthenticationMethodConfiguration(TODO("example omitted"))
.setDcrConfiguration(TODO("example omitted"))
.create(onCoroutineContext = TODO("onCoroutineContext"))
val haapiManager = haapiAccessor.haapiManager
val oauthTokenManager = haapiAccessor.oAuthTokenManager
The listener is triggered when receiving a response from the token endpoint: OAuthTokenManager.SuccessTokenResponseContent, OAuthTokenManager.ErrorTokenResponseContent, or HttpClient.Response.Failure.
When opting for this configuration, the TokenResponse can be ignored as HttpClient.Response contains the raw response.
When handling a HttpClient.Response.Failure with a response code of 0, this means it is not a server error. Instead, an internal failure was detected while executing a request and is being reported.
Note: the SDK may perform parsing and lightweight side-effects when handling token endpoint responses (for example, clearing a token-bound DPoP key on an invalid_grant response). If you need raw headers, content-type or the unparsed body for auditing or diagnostics, configure a tokenEndpointResponseListener in HaapiConfiguration.
Warning: When a tokenEndpointResponseListener is configured, and an exception is raised, the framework delivers the failure to both the listener callback and to the calling method. This can cause double-handling of the same error. Recommended approaches:
- Prefer a single place to handle UX/flow errors: use the listener only for raw logging/audit and let the caller perform user-visible handling.
- If the listener must handle the error (e.g. to clear local state), mark the error as handled with a shared flag that the caller checks before doing its own handling.
Example use
val listenerHandled = java.util.concurrent.atomic.AtomicBoolean(false)
val tokenListener = object : OAuthTokenManager.TokenEndpointResponseListener {
override fun onSuccess(value: OAuthTokenManager.SuccessTokenResponseContent) {
// success: listener handles the OAuth token response and marks it handled
listenerHandled.set(true)
}
override fun onTokenError(value: OAuthTokenManager.ErrorTokenResponseContent) {
// error response: listener handles the OAuth error and marks it handled
// e.g. audit, clear keypairs, report metrics
listenerHandled.set(true)
}
override fun onError(value: HttpClient.Response.Failure) {
// handle failure, audit, report metrics, inspect failure for retryability
listenerHandled.set(true)
}
}
val haapiConfiguration = HaapiConfiguration(
/* TODO: ommited */,
tokenEndpointResponseListener = tokenListener
)
// Before each request reset the flag for this attempt
listenerHandled.set(false)
try {
val tokenResponse = oAuthTokenManager.refreshAccessToken(
refreshToken = "stored_refresh_token",
onCoroutineContext = coroutineContext
)
if (listenerHandled.get()) {
// Listener already processed the success — avoid duplicate handling
} else {
// Caller side handling (handle TokenResponse variants)
}
} catch (err: Exception) {
if (listenerHandled.get()) {
// Listener already processed the failure — avoid duplicate handling
} else {
// Caller-side handling (show UI, retry logic, etc.)
}
}
Can the Haapi Sdk support app widget?
An app widget enables developers to add custom functionality and content to their application, such as information, collection, control or hybrid widgets. More details can be found here.
❗️When using the Haapi Sdk in an app widget, use only the OAuth operations to manage the access and refresh tokens. App widgets have specific limitations, such as not being able to access certain APIs or frameworks marked as unavailable for extensions, and limited access to device resources and lifespan. They are best suited for short interactions and specific actions.
⚠️ Running the Haapi flow or retrieving the access_token is not supported in app widget.
To manage/refresh an access token in an app widget, a few requirements are needed:
- The application and the app widget use the same HaapiConfiguration.
- The
SuccessfulTokenResponsemust be stored in a storage (SharedPreferences, which is shared across the app context and its app widgets) that can be used in the application and the app widget when the Haapi flow reaches the end. - The app widget retrieves the
SuccessfulTokenResponsefrom the storage. The app widget can manage the access/refresh token via OAuthTokenManager. Upon a successful refresh, theSuccessfulTokenResponsemust be stored in the storage, similar to the previous point.
Example use
// Shared Haapi Configuration
val haapiConfiguration: HaapiConfiguration = TODO("omitted for brevity")
// Shared storage
private val appSharedPreferences: SharedPreferences = getSharedPreferences("SharedPreferences", Context.MODE_PRIVATE)
private val ACCESS_TOKEN = "ACCESS_TOKEN"
private val REFRESH_TOKEN = "REFRESH_TOKEN"
// Saving the SuccessfulTokenResponse
fun saveOauthModelToken(token: SuccessfulTokenResponse) {
appSharedPreferences.edit(commit = true) {
putString(ACCESS_TOKEN, token.accessToken)
putString(REFRESH_TOKEN, token.refreshToken)
}
}
// Getting the refresh_token
fun getRefreshToken(): String? {
return appSharedPreferences.getString(REFRESH_TOKEN, null)
}
// Getting the access_token
fun getAccessToken(): String? {
return appSharedPreferences.getString(ACCESS_TOKEN, null)
}
//In the app widget
fun example() {
getRefreshToken()?.let {
GlobalScope.launch(Dispatchers.IO) {
val accessor = HaapiAccessorFactory(haapiConfiguration)
.createForOAuth()
val token = accessor.oAuthTokenManager.refreshAccessToken(
refreshToken = it,
onCoroutineContext = this.coroutineContext
)
when (token) {
is SuccessfulTokenResponse -> {
saveOAuthModelToken(token)
}
is ErrorTokenResponse -> {
// Handle the error token response.
}
}
}
}
}
HttpURLConnectionProvider
When implementing an HttpURLConnectionProvider that returns a HttpURLConnection, it is strongly recommended to explicitly configure both the connectTimeout and readTimeout .
If these values are not set by the provider, the framework will apply default timeouts to prevent requests from hanging indefinitely. Currently, the default values are:
- connectTimeout: 5 seconds
- readTimeout: 10 seconds
Explicitly setting these timeouts in your implementation ensures predictable network behaviour and allows you to tailor timeout handling to your specific use case.
HaapiLogger
When using the HAAPI Sdk it is possible to display the logs in the console as demonstrated below. When running in a project configuration set to DEBUG mode, the HaapiLogger enabled property is set to true. Otherwise, default value is false but can be set by the developer.
class ClientApplication : Application(), HaapiUIWidgetApplication {
override fun onCreate() {
super.onCreate()
HaapiLogger.enabled = true
}
}
Supported log levels
HaapiLogger only uses the following configurable log levels:
| Log level | How to enabled | Usage |
|---|---|---|
| Error | setLevel(LogLevel.ERROR) |
Used when the application hits an issue preventing one or more functionalities from properly functioning. |
| Warning | setLevel(LogLevel.WARN) |
Used when something unexpected happened in the application that might disturb its functionality. |
| Info | setLevel(LogLevel.INFO) |
Used when something happens, the application entered a certain state. |
| Debug | setLevel(LogLevel.DEBUG) |
Used for information that may be needed for diagnosing issues and troubleshooting or when running application in test environment. |
| Verbose | setLevel(LogLevel.VERBOSE) |
Used in rare cases where you need the full visibility of what is happening. These logs are never compiled into an application except during development. |
The table above reads the log level rank as ERROR being the lowest and Verbose being the highest. When setting a log level, it will also enable the lower levels logs. A log request of level p in a logger with level q is enabled if p >= q. It assumes that levels are ordered. For the standard levels, we have VERBOSE < DEBUG < INFO < WARN < ERROR. For example configuring the logger for level INFO will output log statements for INFO, WARNING and ERROR.
When running in a project configuration set to DEBUG mode, the default LogLevel is set to VERBOSE. Otherwise, default value is ERROR but can be set by the developer.
Display masking sensitive data
To remove the masking, HaapiLogger.isSensitiveValueMasked has to be set to false. Now with this configuration, logs are displayed without masking values but occasionally additional warning logs are added to remind this setting should not be enabled except for testing purposes such as:
2023-11-02 11:12:15.756 32737-32737 se.curity....HaapiTokenManager com.example.myhaapiui D HAAPI_DRIVER_FLOW - Received CAT challenge with: *****OWFFpT2lKT
To remove the masking, HaapiLogger.isSensitiveValueMasked has to be set to false. Now with this configuration, logs are displayed without masking value but followed with warnings logs such as:
2023-11-02 11:12:15.756 32737-32737 se.curity....HaapiTokenManager com.example.myhaapiui D ***** SENSITIVE VALUE IS UNMASKED *****
2023-11-02 11:12:15.756 32737-32737 se.curity....HaapiTokenManager com.example.myhaapiui D ***** HaapiLogger.isSensitiveValueMasked must be set to true in `release` mode. *****
2023-11-02 11:12:15.756 32737-32737 se.curity....HaapiTokenManager com.example.myhaapiui D HAAPI_SDK_HTTP - A new session id is set - a-65de12c0-870509f4-e6e5-4269-b651-ce8a6f798753###9d70c13cd8a3c4b218aa3404c99491afc44d5060bf6bba21c3698bb6fb4bfa05
❗️Setting this value to false is only recommended when debugging.
Read the logs
All logs are structured like the following:
2023-11-02 11:12:15.756 32737-32737 se.curity....HaapiManager com.example.myhaapiui D HAAPI_SDK_FLOW - start() is invoked
It is possible to filter the Haapi logs by using the prefix: HAAPI.
To filter Driver logs, use the prefix: HAAPI_DRIVER. Here are the following up tags for the driver:
- HAAPI_DRIVER_ATTESTATION: Logs related to the attestation flow.
- HAAPI_DRIVER_DCR: Logs related to the DCR flow.
- HAAPI_DRIVER_FLOW: Logs related to the driver flow.
- HAAPI_DRIVER_HTTP: Logs related to any http calls in the driver.
- HAAPI_DRIVER_STORAGE: Logs related to any storage related calls in the driver.
- HAAPI_DRIVER_KEYSTORE: Logs related to any keystore related calls in the driver.
To filter Sdk logs, use the prefix: HAAPI_SDK. Here are the following up tags for the sdk:
- HAAPI_SDK_FLOW: Logs related to the sdk flow.
- HAAPI_SDK_HTTP: Logs related to any http calls in the sdk.
- HAAPI_SDK_MAPPING: Logs related to mapping objects in the sdk.
- HAAPI_SDK_OAUTH: Logs related to OAuth in the sdk.
- HAAPI_SDK_STORAGE: Logs related to any storage related calls in the sdk.
Write logs to another destination
It is possible to write the logs to another destination as demonstrated below.
class MyLogSink : LogSink {
override fun writeLog(
logLevel: HaapiLogger.LogLevel,
sender: String?,
followUpTag: HaapiLogger.FollowUpTag?,
message: String,
throwable: Throwable?
) {
// Filter/export to your designated tool.
}
}
HaapiLogger.appendLogSink(MyLogSink())
WebAuthn and Passkeys
When the app needs to trigger a WebAuthn Authorization interaction flow, it can do so by either delegating responsibility on the OS by opening a browser or by using an integrated native flow inside the app.
Native WebAuthn support is provided by:
- Google Play Services Fido2 API.
- Credential Manager: A Jetpack credentials API. Supports built-in Platform authenticators (Android 7+ devices) as well as Cross-platform external hardware Security Keys authenticators.
⚠️ Cross-platform Security-Keys with user verification are currently not supported.
Native WebAuthn support for Android is available in Curity Identity Server starting from version 8.7.0.
To use passkeys on Android, you need to meet certain requirements. Passkeys are supported on devices running Android 9 (Pie) or later. Here are the key requirements and details:
- Android Version: Your device must be running Android 9 (Pie) or a newer version.
- Biometric Authentication: You need a biometric sensor, such as a fingerprint or facial recognition, or a PIN, swipe pattern, or password to authenticate and create a passkey.
- Google Account: You must be signed in to a Google Account on your device.
- Credential Manager: Passkeys are managed through the Credential Manager Jetpack library, which handles different credential types including passkeys, passwords, and identity federation.
- Google gms Fido2: Passkeys are managed through the Fido2ApiClient which handles passkeys
- Google Password Manager: Passkeys can be stored in the Google Password Manager, which synchronizes them between devices signed into the same Google account. This allows you to use passkeys across multiple devices.
- Third-party Password Managers: Starting with Android 14, users can opt to store their passkeys in a compatible third-party password manager.
- Website and App Support: The website or app you want to access must support passkey login.
- Nearby Device Authentication: For cross-device login, your phone must be near the device you're logging into, and you must approve the sign-in on your phone.
- PIN or Biometric Authentication: When creating or using a passkey, you'll need to authenticate using a PIN, swipe pattern, or biometric sensor.
Discoverable Credentials support for Android is available in Curity Identity Server starting from version 9.3.0.
Error handling
Both HaapiManager and OAuthTokenManager surface failures through the shared IdsvrHaapiException hierarchy defined in the Driver module. They apply a best-effort strategy that retries the underlying HTTP request exactly once whenever the driver reports a retryable subtype (for example HttpClientRetryableException.SocketStreamInterruptionException, HostConnectionException, HttpRetryException, or HaapiRetryableException.UseDpopNonceException). If that single retry also fails, the exception is delivered to the application code so the developer can decide whether further retries are safe.
try {
val response = haapiManager.start(coroutineContext)
handleHaapiResponse(response)
} catch (error: Exception) {
when (error) {
is IdsvrHaapiException.Retryable -> scheduleRetry(error)
is IdsvrHaapiException.Unrecoverable -> presentFailure(error)
else -> handleOtherErrors(error)
}
}
Handling Retryable Errors
Retryable errors indicate temporary issues that may be resolved by attempting the operation again under specific conditions.
suspend fun handleRetryableError(exception: IdsvrHaapiException.Retryable) {
when (exception.condition) {
is RetryCondition.Now -> {
// Retry immediately with exponential backoff
retryWithBackoff(exception)
}
is RetryCondition.WhenAppForeground -> {
// Queue for retry when app comes to foreground
queueForForegroundRetry(exception)
}
}
}
Use the RetryCondition exposed by retryable exceptions to decide whether to retry immediately or defer until the app is foregrounded.
Handling Unrecoverable Errors
Unrecoverable errors require manual intervention and cannot be resolved through automatic retry.
fun handleUnrecoverableError(exception: IdsvrHaapiException.Unrecoverable) {
when (exception.action) {
is UnrecoverableAction.ModifyConfiguration -> {
handleConfigurationError(exception)
}
is UnrecoverableAction.InvalidPlatform -> {
handlePlatformError(exception)
}
is UnrecoverableAction.IntrospectCause -> {
handleInvestigationRequired(exception)
}
}
}
For unrecoverable cases, inspect error and errorDescription to craft actionable UX. Refer to the Driver documentation’s “IdsvrHaapiException Error Handling Guide” for the complete matrix that explains how each low-level networking condition maps to these exception types and what remediation is expected on the client side.
Version upgrade notes
5.2.0
HaapiManager — error handling & state (migration guidance)
This note explains behaviour changes in HaapiManager that affect how client code should handle responses, retries, token/session side-effects, and exceptions.
Key points
- Single automatic retry: The SDK performs exactly one automatic retry for transient framework/transport level retryable conditions (for example, DPoP nonce refresh or transport retryable exceptions). This happens inside
HaapiManager. - Response mapping rules:
-
- Failure responses with content-type
application/haapi,application/problem+jsonandapplication/jsonmay rethrow an underlyingIdsvrHaapiException(framework-level), or cause anHttpClientUnrecoverableException.UnexpectedExceptionwhen parsing fails. - Other failure payloads either propagate framework exceptions or are wrapped as
UnexpectedException.
- Failure responses with content-type
- Interceptors:
-
HaapiManagerwires optionalinterceptorandrequestInterceptorinto the internalhttpClient. Use these interceptors to inspect raw requests/responses (headers, raw JSON) when needed.
Migration checklist for client code
- Catch framework-level exceptions explicitly (prefer
IdsvrHaapiException.RetryableandIdsvrHaapiException.Unrecoverablebranches) when callingstart,submitForm,followLink. - If you need raw HTTP headers or the raw JSON body for auditing, set a
requestInterceptororinterceptoronHaapiManager.
Migration examples (recommended patterns)
Before (minimal handling):
GlobalScope.launch(Dispatchers.IO) {
try {
val response = haapiManager.start(this.coroutineContext)
when (response) {
is HaapiRepresentation -> showRepresentation(response)
is ProblemRepresentation -> handleProblem(response)
}
} catch (e: Exception) {
// old code might catch Exception only
showError(e)
}
}
After (explicitly handle framework exceptions):
GlobalScope.launch(Dispatchers.IO) {
try {
val response = haapiManager.start(onCoroutineContext = this.coroutineContext)
when (response) {
is HaapiRepresentation -> showRepresentation(response)
is ProblemRepresentation -> presentProblemToUser(response)
}
} catch (err: IdsvrHaapiException.Retryable) {
// transient, implement retry/backoff logic after inspecting exception content to infer if retry should be performed
scheduleRetry(err)
} catch (err: IdsvrHaapiException.Unrecoverable) {
// unrecoverable; present configuration/inspection UI
presentFailure(err)
} catch (err: HttpClientUnrecoverableException.UnexpectedException) {
// parsing/unsupported payload
logAndReport("Unexpected payload", err)
showGenericError()
} catch (err: Exception) {
// fallback
logAndReport("Other error", err)
showGenericError()
}
}
submitForm / followLink (recommended handling):
suspend fun onSubmit(form: FormActionModel, params: Map<String, Any>) {
try {
val result = haapiManager.submitForm(form = form, parameters = params)
when (result) {
is HaapiRepresentation -> handleRepresentation(result)
is ProblemRepresentation -> handleProblemRepresentation(result)
}
} catch (err: IdsvrHaapiException.Retryable) {
// transient error - schedule retry/backoff after inspecting exception content to infer if retry should be performed
scheduleRetry(err)
} catch (err: IdsvrHaapiException.Unrecoverable) {
presentFailure(err)
}
}
Low-level: using an interceptor to inspect raw HTTP metadata
val responseInterceptor = Interceptor<HttpClient.Response> { response ->
// Inspect headers/contentType/rawJsonString
log("response headers: ${'$'}{response.headerFields}")
response // return unmodified response
}
val manager = HaapiManager(haapiConfiguration, interceptor = responseInterceptor)
Recommended tests to add after upgrading
- Malformed HAAPI payload: return malformed JSON with
application/haapi-> assertHttpClientUnrecoverableException.UnexpectedExceptionis thrown.
OAuthTokenManager — error handling (what changed and migration steps)
This section explains behavioral changes introduced by the current OAuthTokenManager implementation that affect how client code should handle token requests, revocation flows, and token-bound (DPoP) key lifecycle.
Summary of behavioral changes
- Single automatic retry on token operations: OAuth token requests (fetch/refresh) and revocation perform a single automatic retry when the framework reports retryable conditions (for example, transport-level transient errors or DPoP nonce conditions). If the retry fails, the framework exception is propagated to the caller.
- Parsed OAuth errors vs exceptions:
-
- If an HTTP failure from the token endpoint contains a JSON OAuth error body (for example
{ "error": "invalid_grant", "error_description": "..." }), the SDK returns anErrorTokenResponsewith parsed fields. - If there is no parsable OAuth body but the underlying driver surfaces an
IdsvrHaapiException, that exception is rethrown to the caller — callers must explicitly catchIdsvrHaapiExceptionvariants. - Unexpected content types or malformed responses cause
HaapiUnrecoverableException.UnexpectedExceptionto be thrown.
- If an HTTP failure from the token endpoint contains a JSON OAuth error body (for example
- Automatic token-bound (DPoP) keypair deletion:
-
- When a token endpoint returns an OAuth error with
error == "invalid_grant", the SDK automatically clears any locally stored token-bound (DPoP) keypair to avoid reusing an unacceptable key.
- When a token endpoint returns an OAuth error with
- Revocation errors:
-
- Revocation endpoints returning HTTP 4xx with an OAuth-style JSON body are surfaced as
OAuthUnrecoverableExceptionpopulated with parsederror,errorDescription, anderrorUri. - Other revocation failure modes propagate the underlying throwable.
- Revocation endpoints returning HTTP 4xx with an OAuth-style JSON body are surfaced as
- Token endpoint response listener:
-
- If
tokenEndpointResponseListeneris configured inHaapiConfiguration, you receive raw and parsed response objects throughonSuccess,onTokenError, andonErrorcallbacks — useful for headers, auditing, or raw JSON inspection.
- If
Why this matters to your app
- Catch framework-level exceptions (
IdsvrHaapiException) in addition to inspectingTokenResponsevariants — these exceptions represent framework/transport conditions that the SDK will rethrow when no OAuth body is available. - Treat
invalid_grantresponses as a re-authentication signal; the SDK deletes the DPoP keypair automatically so your app should prompt the user to re-authenticate rather than attempting to reuse keys or refresh tokens. - Use
tokenEndpointResponseListenerif you need access to headers, raw JSON, or content-type for logging or policy decisions.
Migration examples (Kotlin + coroutines)
Before (legacy, naive handling):
val response = oAuthTokenManager.refreshAccessToken(refreshToken, onCoroutineContext = context)
when (response) {
is SuccessfulTokenResponse -> useTokens(response)
is ErrorTokenResponse -> {
if (response.error == "invalid_grant") {
// Old behavior: react here
} else { /* handle other errors */ }
}
}
After (recommended handling):
try {
val response = oAuthTokenManager.refreshAccessToken(refreshToken, onCoroutineContext = context)
when (response) {
is SuccessfulTokenResponse -> useTokens(response)
is ErrorTokenResponse -> when (response.error) {
DriverErrorCodes.INVALID_GRANT -> {
// SDK already deleted token-bound keypair; discard held token and prompt re-authentication
promptRelogin()
}
else -> handleOAuthError(response)
}
}
} catch (err: IdsvrHaapiException.Retryable) {
// transient error - schedule retry/backoff
scheduleRetry(err)
} catch (err: IdsvrHaapiException.Unrecoverable) {
// unrecoverable — surface to the user / log
presentFailure(err)
} catch (err: Exception) {
// Other unexpected errors
handleOtherErrors(err)
}
Handling revocation (note OAuthUnrecoverableException):
try {
oAuthTokenManager.revokeRefreshToken(refreshToken, onCoroutineContext = context)
// Success — SDK also deletes token-bound keypair.
} catch (err: OAuthUnrecoverableException) {
// Revocation returned 4xx with OAuth error JSON -> parsed into exception
showRevocationError(err.error, err.errorDescription, err.errorUri)
} catch (err: IdsvrHaapiException.Retryable) {
// Be aware that the endpoint doesn't allow retries in some cases as the server already processed the revocation.
scheduleRetry(err)
} catch (err: Exception) {
handleOtherErrors(err)
}
Using the TokenEndpointResponseListener
- To inspect raw HTTP headers, content-type, or raw JSON, set
tokenEndpointResponseListenerinHaapiConfiguration. The listener will receive: -
onSuccess(SuccessTokenResponseContent)— successful token response with raw fieldsonTokenError(ErrorTokenResponseContent)— parsed OAuth error with raw fieldsonError(HttpClient.Response.Failure)— transport/driver failures and raw body if present
Recommended tests to add after upgrading
- invalid_grant handling
-
- Simulate token endpoint returning
error = "invalid_grant". - Assert SDK returns
ErrorTokenResponseand deletes the token-bound keypair; ensure app prompts re-authentication.
- Simulate token endpoint returning
- Non-JSON / unexpected content-type
-
- Simulate token endpoint returning non-JSON or malformed payload.
- Assert call throws
HaapiUnrecoverableException.UnexpectedExceptionand your app surfaces parsing error.
- Revocation 4xx JSON parsing
-
- Simulate revocation endpoint returning 400 with OAuth error JSON.
- Assert
revokeRefreshTokenthrowsOAuthUnrecoverableExceptionwith parsed fields.