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
minSdk26 andcompileSdk37. The library’s metadata requirescompileSdk37 or later, and Gradle fails the build withcheckDebugAarMetadataotherwise. The libraries declare notargetSdk; 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_accessfor 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"),
),
)
| Field | Meaning |
|---|---|
issuer | The provider’s issuer. Discovery runs on the first operation that needs it and is cached |
redirectUri | An 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 |
scopes | Default scopes for login(). openid is added if missing. Nothing else is added, including offline_access |
protectedOrigins | The only origins that ever receive a token: scheme://host[:port], no path, https (or http on a loopback host) |
pushedAuthorization | PushedAuthorization.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 |
tokenBinding | TokenBinding.Bearer by default. TokenBinding.Dpop() opts in to DPoP, see Sender-constrained tokens |
retryPolicy | RetryPolicy.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
OAuthClientagainst 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
- Session states and errors: what each state and error category asks of your app.
- Server errors, 429 and Retry-After: what the SDK retries when a provider answers 500, 503 or 429.
- Provider guides: the provider contract, and the
offline_accessand sign-out differences.