The Kotlin/JVM OpenID Connect implementation used a temporary loopback server to bring the browser back into a desktop process.
Android needs a different boundary. The authorization request still belongs in the system browser, but the result returns through an intent. The Activity has to recognize the redirect, reconnect it to the suspended sign-in operation and ignore every unrelated deep link.
The protocol should not be rewritten for that platform difference.
Siere KMP Auth now keeps discovery, PKCE, token exchange, signed ID-token validation, refresh and restoration in common Kotlin. Android supplies the browser and deep-link bridge. The published adapter is dev.siere.auth:auth-oidc; the library setup guide carries the current installation coordinates.
The API is common; the redirect is Android
The provider is configured with the same public-client policy used on the other targets:
val provider = OidcAuthProvider(
configuration = OidcConfiguration(
clientId = "android-app",
discoveryUrl =
"https://identity.example/.well-known/openid-configuration",
scopes = setOf("profile", "email", "offline_access"),
providerId = "identity.example",
),
redirectUri = "com.example.app://oauth/callback",
authorizationHandler = androidAuthorizationHandler,
sessionStore = secureSessionStore,
)
val auth = SiereAuth(provider)
The application still calls the provider-neutral API:
val signIn = auth.signInWithOpenId()
val session = auth.currentSession(forceRefresh = false)
val signOut = auth.signOut()
OidcAuthProvider does not depend on an Activity. It accepts an OidcAuthorizationHandler, which is the small platform boundary between common protocol code and Android UI.
Android does not need a localhost server
A desktop application can bind a random loopback port and wait for the browser. Android already has an operating-system routing mechanism: an intent filter.
The sample registers this callback:
dev.siere.auth.sample://oauth/callback
The matching manifest entry is deliberately narrow:
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="dev.siere.auth.sample"
android:host="oauth"
android:path="/callback" />
</intent-filter>
</activity>
Scheme, host and path are all fixed. A broad filter such as a scheme without a host would let the Activity receive URLs that have nothing to do with authentication.
singleTask sends a returning browser intent to onNewIntent() when the Activity already exists. A cold process can receive the same URL through the original onCreate() intent. The application has to cover both paths.
Register a public client, not an Android secret
The identity provider registration uses Authorization Code Flow and requires PKCE with S256.
For the sample Keycloak realm, the client is:
client_id: siere-android-oidc
redirect_uri: dev.siere.auth.sample://oauth/callback
client authentication: off
PKCE method: S256
There is no client secret in the application or the library API. An APK can be inspected, and a value shipped inside it cannot authenticate the app as a confidential client.
The redirect URI in OidcAuthProvider, the Android manifest and the identity provider registration must match exactly. Changing only one of them should fail rather than fall back to a looser redirect.
Suspend the sign-in while the browser is open
The sample handler starts the browser and waits on a CompletableDeferred:
private class AndroidOidcAuthorizationHandler(
private val activity: ComponentActivity,
) : OidcAuthorizationHandler {
private var pendingCallback: CompletableDeferred<String>? = null
override suspend fun authorize(request: OidcAuthorizationRequest): String {
check(pendingCallback == null) {
"An OIDC authorization request is already active"
}
val callback = CompletableDeferred<String>()
pendingCallback = callback
activity.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(request.authorizationUrl)),
)
return try {
callback.await()
} finally {
if (pendingCallback === callback) pendingCallback = null
}
}
fun complete(callbackUrl: String) {
if (callbackUrl.substringBefore('?').substringBefore('#') == REDIRECT_URI) {
pendingCallback?.complete(callbackUrl)
}
}
}
Only one interactive request may be active in this handler. The Activity performs a cheap exact redirect check before completing it:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
acceptOidcCallback(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
acceptOidcCallback(intent)
}
private fun acceptOidcCallback(intent: Intent) {
intent.dataString?.let(authorizationHandler::complete)
}
That Activity check is routing, not the security decision. The library validates the complete callback before it exchanges a code.
The callback remains untrusted input
Android delivered the intent, but that does not prove that the active authorization server created it.
Siere rejects the callback unless:
- Its URI matches the registered redirect exactly
- It has no fragment
- Each security-sensitive query parameter appears at most once
- Its cryptographic
statematches the current operation - Its optional issuer parameter matches the discovered issuer
- It contains either a valid authorization code or a mapped OAuth error
The authorization code is then exchanged with the original redirect URI and PKCE verifier. Access, refresh and ID tokens never travel through the Android deep link.
The handler also cancels its pending continuation when the Activity is destroyed. Provider shutdown cancels the active operation, closes HTTP resources and prevents a late callback from reviving a closed session.
PKCE, state and nonce defend different boundaries
Every sign-in creates fresh values for three separate checks:
- PKCE binds the authorization code to the app instance that created the verifier.
statebinds the callback to the browser operation currently waiting in the Activity.- The nonce binds the signed ID token to that same authentication attempt.
Collapsing them into one value would mix responsibilities and make failures harder to reason about. Siere generates and validates each independently.
The token exchange identifies the public client but sends no secret:
grant_type=authorization_code
client_id=android-app
code=<authorization-code>
redirect_uri=com.example.app://oauth/callback
code_verifier=<original-verifier>
Identity still comes from a verified ID token
The platform changed; the OIDC trust chain did not.
Siere discovers the issuer, authorization endpoint, token endpoint and JWKS endpoint. It supports the RS256, RS384, RS512, ES256, ES384 and ES512 signing families and rejects none or an algorithm outside the provider's advertised allow-list.
Before publishing AuthState.SignedIn, it verifies the signature and checks:
iss == discovered issuer
sub is present
aud contains the configured client ID
azp == client ID when aud has several values
exp has not passed
iat is not unreasonably in the future
nbf, when present, has passed
nonce == the value created for this browser operation
When a token references an unknown key, the verifier reloads JWKS once to handle normal key rotation. RSA signing keys smaller than 2048 bits are rejected.
The normalized user ID combines issuer and subject. The same sub value from two identity providers must not become the same application user.
Session storage belongs to the host application
The cross-platform default is InMemoryOidcSessionStore. It supports refresh while the provider is alive and intentionally loses the session when the process exits.
An Android application that needs restart restoration should implement OidcSessionStore with a credential store appropriate for its threat model. The interface receives sensitive bytes and owns confidentiality, integrity, atomic replacement and deletion.
Restoration does not trust serialized identity claims. Siere checks that the stored configuration still belongs to the current client, discovers the provider again and verifies the stored ID-token signature, issuer and time claims before publishing a signed-in state. Invalid credentials clear the stored session. A transient storage or network failure leaves the store intact and reports a signed-out runtime state instead of destroying potentially recoverable credentials.
Refresh responses are checked as strictly as the original sign-in. A replacement ID token cannot switch issuer or subject. If a provider rotates the refresh token, the new value is stored; if it omits one, the previous refresh token is retained.
Custom scheme or verified App Link?
The local sample uses a reverse-domain custom scheme because it works with a disposable Keycloak realm and an emulator without owning a public HTTPS domain.
Custom schemes are not exclusive: another installed application can declare the same scheme. PKCE prevents that application from redeeming the intercepted authorization code, and state prevents it from injecting another operation's callback, but the collision can still disrupt the user's sign-in.
For a production application that controls a domain and whose identity provider accepts HTTPS redirects, prefer a verified Android App Link. Android verifies the relationship between the HTTPS host and the installed package before routing the URL. That requires an autoVerify intent filter and an assetlinks.json file on the controlled domain; it is deployment configuration rather than OIDC protocol code, so the full setup is intentionally outside this article.
If a provider requires a custom scheme, use a globally specific reverse-domain value, keep the filter narrow and retain PKCE, state and exact callback validation.
Run the local Android flow
The repository contains a disposable Keycloak realm with separate public PKCE clients for JVM and Android. The Android client and demo account are source-controlled test fixtures, not production credentials.
Start Keycloak:
./gradlew :sample-jvm-oidc:keycloakUp
Build and install the Android sample, then make the host Keycloak port available to the emulator:
./gradlew :sample:assembleDebug
adb reverse tcp:8080 tcp:8080
adb install -r sample/build/outputs/apk/debug/sample-debug.apk
Open the app, choose Local OIDC, and sign in with:
username: demo
password: demo-password
The flow opens the Keycloak login page in the system browser and returns to a signed-in Siere Demo state. The sample uses cleartext HTTP only for numeric loopback testing; the library rejects non-loopback HTTP discovery even when its test flag is enabled.
What we verified
The release was not accepted on compilation alone.
The live Android check used a Pixel 9a Android 15 emulator and Keycloak 26.7.3. It completed browser sign-in, returned through the custom-scheme intent, rendered Signed in as Siere Demo, forced an access-token refresh and signed out.
The automated suite covers successful authorization, wrong state, malformed and duplicate callback parameters, redirect validation, provider errors, timeout, cancellation, discovery failures, RS and ES signatures, key rotation, refresh-token preservation, identity-switch rejection, restored-token revalidation and transient store failures.
The same release gate executes the shared protocol tests on JVM, JavaScript and Wasm browsers, plus the iOS simulator. Android assembles the real sample and runs its unit tests. Public API checks protect every published target surface.
The application still owns the Android boundary
Siere owns the protocol invariants that should be identical on every target. The Android application still owns:
- Its exact public-client and redirect registration
- The browser experience and lifecycle bridge
- A durable credential store, when required
- App Link domain verification or a carefully chosen custom scheme
- Backend validation of access-token issuer and audience
- User-facing handling of cancellation and provider errors
That split keeps the common library useful without pretending an Android Activity, an iOS authentication session and a browser popup are the same thing.
The source, Android sample, Keycloak fixture and current installation instructions are in Siere KMP Auth. The corresponding release is available on the project releases page.