Android — Versola Docs
/docs
0.1.0versola.ioGitHub

Android

Add the Versola OAuth SDK to an Android app from Maven Central, declare the App Link redirect, sign in, call an API with OkHttp and handle errors.

This guide takes an Android app from nothing to a signed-in user and an authenticated API call, using OAuthClient from the Versola OAuth SDK. You need a public client registered at your provider and a host you control for the App Link.

Prerequisites

  • minSdk 26 and compileSdk 37. The library’s metadata requires compileSdk 37 or later, and Gradle fails the build with checkDebugAarMetadata otherwise. The libraries declare no targetSdk; that is your app’s decision.
  • A public client registered at your provider, with the exact redirect URI you will use. A provider that needs offline_access for a refresh token is listed on Provider guides.
  • A host you control for the App Link, where you can serve /.well-known/assetlinks.json.

Install

The artefact is io.versola:oauth-android on Maven Central. Add mavenCentral() and google() to the repositories, and the dependency to the app module.

// settings.gradle.kts of the app
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
// build.gradle.kts of the app module
android {
    compileSdk = 37
    defaultConfig {
        minSdk = 26
    }
}

dependencies {
    implementation("io.versola:oauth-android:<version>")
}

OAuthClient depends on the protocol core and the DPoP, storage, browser and OkHttp modules, so one dependency brings them all. Their types are on its public surface, for example SessionState, VersolaError and VersolaRedirectActivity.

Configure the client

import versola.oauth.OAuthClient
import versola.oauth.OAuthConfiguration

val oauth = OAuthClient.create(
    applicationContext,
    OAuthConfiguration(
        issuer = "https://id.example.com",
        clientId = "acme-app",
        redirectUri = "https://app.example.com/oauth",
        postLogoutRedirectUri = "https://app.example.com/signed-out",
        scopes = setOf("openid", "profile", "offline_access"),
        protectedOrigins = setOf("https://api.example.com"),
    ),
)
FieldMeaning
issuerThe provider’s issuer. Discovery runs on the first operation that needs it and is cached
redirectUriAn https App Link you own (preferred), a loopback address, or a private-use scheme. The SDK accepts a private-use scheme with a logged warning on every create(), because any app can register the same scheme. Cleartext http to a non-loopback host is refused
scopesDefault scopes for login(). openid is added if missing. Nothing else is added, including offline_access
protectedOriginsThe only origins that ever receive a token: scheme://host[:port], no path, https (or http on a loopback host)
pushedAuthorizationPushedAuthorization.WhenAdvertised by default: the SDK pushes the request when the provider advertises a PAR endpoint. Never and Required are the other values, see Authorization request
tokenBindingTokenBinding.Bearer by default. TokenBinding.Dpop() opts in to DPoP, see Sender-constrained tokens
retryPolicyRetryPolicy.Default: how often and how long the SDK repeats a failed request, see Server errors, 429 and Retry-After

create does no network I/O and no key generation. A second call with the same issuer and client ID returns the same instance. A different configuration for the same pair is refused with Configuration(InvalidConfiguration). OAuthConfiguration has no clientSecret parameter, on purpose.

Declare the redirect

The app declares its redirect URI by re-declaring VersolaRedirectActivity in its own manifest. The manifest merger unions it with the library’s declaration. For a verified App Link:

<activity
    android:name="versola.sdk.browser.VersolaRedirectActivity"
    android:exported="true"
    tools:node="merge">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" android:host="app.example.com" android:path="/oauth" />
    </intent-filter>
</activity>

The tools:node attribute needs the tools namespace declared on the manifest’s root element. The host and path must match redirectUri and be your app’s own verified App Link. Serve /.well-known/assetlinks.json for the app’s package name and signing certificate on that host.

The browser leg is an Auth Tab where the device’s Custom Tabs provider supports it, and a Custom Tab otherwise, with any browser as the last resort. An Auth Tab returns the redirect to the app that opened it. Under an Auth Tab, an https redirect URI that is not a verified App Link of the app makes the tab answer with a verification failure, and that surfaces as Configuration(InvalidConfiguration). The same applies to postLogoutRedirectUri: if it isn’t verified, the tab closes unverified and logout() reports sessionEnded = false.

LoginOptions(ephemeralBrowser = true) asks for a browser session that shares nothing with the user’s browser. It is off by default because it breaks passkeys and single sign-on, and it is best effort: where the provider’s browser doesn’t support it, the SDK logs one warning and opens a shared session.

Sign in, call an API, sign out

import okhttp3.Request
import versola.oauth.LoginOptions
import versola.oauth.core.VersolaError

suspend fun signInAndCall() {
    try {
        oauth.login(LoginOptions(scopes = setOf("openid", "profile", "offline_access")))
        val request = Request.Builder().url("https://api.example.com/me").build()
        oauth.call(request).use { response -> handle(response.code) }
    } catch (e: VersolaError.StepUp) {
        oauth.stepUp(e)                         // then repeat the call
    }
}

suspend fun signOut() {
    val outcome = oauth.logout()               // never throws for a server-side failure
    // outcome.sessionEnded and outcome.tokenRevoked say which server-side steps worked
}

login() returns nothing. The session is observable through sessionState, and claims holds the validated ID token’s claims. logout() revokes the refresh token, ends the provider session in the browser and wipes local state. Only the wipe is unconditional: the two server-side steps are best effort, and LogoutOutcome tells you which succeeded.

To push the request with PAR, or to pass prompt, loginHint, resources or provider parameters, see Authorization request: PAR and extra parameters.

Observe the session state

sessionState is a cold Flow<SessionState> whose first emission is the restored state. A stored grant restores as Authenticated, a pending authorization younger than 10 minutes as Authorizing, and anything else as Unauthenticated.

import versola.oauth.core.SessionState

lifecycleScope.launch {
    oauth.sessionState.collect { state ->
        if (state == SessionState.Authenticated) showSignedIn()
    }
}

Calling login() while the state is Authorizing is refused with Configuration(InvalidConfiguration), so observe the state before you ask. The seven states are on Session states and errors.

Call your API with OkHttp

oauth.call(request) and oauth.authenticatedClient() use the protectedOrigins of the configuration, or you can pass a set per call. To install the session on an OkHttpClient of your own:

val client = OkHttpClient.Builder()
    .addInterceptor(oauth.interceptor(setOf("https://api.example.com")))
    .build()

The interceptor is an application interceptor. It does the following:

  • Attaches the token only to the listed origins and lets every other request through untouched.
  • On a 401, refreshes once and replays once.
  • Retries once on a DPoP nonce challenge.
  • Turns an RFC 9470 challenge into StepUp.

An interceptor can only raise an IOException, so errors reach your own client as VersolaHttpException. Its error property is the VersolaError. oauth.call unwraps it for you. authenticatedClient() also switches redirects off, so a Location from a protected origin can’t repeat the request, token included, at another origin. If you use interceptor on your own client, switch redirects off yourself.

oauth.accessToken().value is the escape hatch for HTTP stacks that are not OkHttp. A token handed to app code is one the app can log.

Handle errors

VersolaError is the error type on Android, and its category tells you what to do. The four categories are Recoverable, Reauthenticate, StepUp and Configuration:

import versola.oauth.core.VersolaError

try {
    oauth.login()
} catch (e: VersolaError) {
    when (e) {
        is VersolaError.Recoverable -> when (val reason = e.reason) {
            is VersolaError.Recoverable.Cause.Server -> retryLater(reason.retryAfterMillis)   // null: the SDK spent its budget
            else -> retryOrAskAgain(reason)
        }
        is VersolaError.Reauthenticate -> showSignIn(e.reason)
        is VersolaError.StepUp -> oauth.stepUp(e)   // then repeat the refused call
        is VersolaError.Configuration -> throw e    // a deployment mistake, not a user problem
    }
}

The when is exhaustive. Session states and errors lists what each category means and which causes land in it. When a provider answers 500, 503 or 429, the Recoverable cause is Server: see Server errors, 429 and Retry-After for what the SDK retries before you see it and for the full example.

Where are tokens and keys stored?

On Android, the refresh token, the raw ID token and the pending request live in Keystore-wrapped files under noBackupFilesDir, so they are not backed up. The refresh token is never handed to your code.

DPoP is opt-in. Set tokenBinding = TokenBinding.Dpop() (from versola.oauth.core) in the configuration. It applies when the provider advertises ES256, and otherwise the SDK uses bearer and logs one warning. keyAttestation() reports the Keystore tier the key reached: StrongBox, TrustedEnvironment or Software. Under Dpop(requireHardware = true), a device that can’t honour the requirement gets a Configuration error instead of a downgrade. The key is non-exportable, not gated on user presence, and replaced on every login().

Does the SDK need R8 or ProGuard rules?

No. The SDK needs no consumer keep rules. Its sample app builds a release variant with R8 (isMinifyEnabled = true, the stock proguard-android-optimize.txt) and an empty rules file, and serialization is reflection-free. A missing-class warning from R8 is a bug in the SDK, not a reason to add rules in your app.

How is the Android SDK tested?

  • JVM and Robolectric unit tests cover the protocol rules against stubs.
  • Keystore tests on an emulator cover the real Keystore, key tiers and GCM wrapping for storage and DPoP.
  • Integration tests run OAuthClient against Keycloak 26.4 and Ory Hydra v26.2.0 containers.
  • A sample app builds a minified release and runs an instrumented smoke test on an emulator.

Next steps