Mobile
Sign in with NIA — Android
Add “Sign in with NIA” to a native Android app with AppAuth-Android, Custom Tabs, and the standard OIDC Authorization Code + PKCE flow.
01Overview
This guide adds “Sign in with NIA” to a native Android application using the standard OpenID Connect Authorization Code flow with PKCE. NIA is a conformant OIDC provider, so you use AppAuth-Android — the standard OAuth/OIDC client for Android — with no NIA-specific SDK.
Android apps are public clients: there is no client secret embedded in the app. AppAuth launches the authorization request in a Chrome Custom Tab and generates the PKCE verifier/challenge for you, so the code never touches an app-embedded secret.
- Flow: Authorization Code + PKCE (S256), public client
- Tokens: RS256 access & ID tokens, opaque refresh token
- You write: a redirect activity config, login launch, token exchange, and an
AuthStatestore
02Requirements
- Android Studio with the Android SDK (API 23+) and Kotlin
- A NIA account with access to the client dashboard
- A registered OAuth public/native client (
client_idonly, no secret) - Your app's custom-scheme redirect URI registered on that client
- A device or emulator with Chrome (or another Custom Tabs provider) installed
03Installation
Add the AppAuth-Android dependency to your app module.
dependencies {
implementation "net.openid:appauth:0.11.1"
}04Required OAuth library
AppAuth discovers every endpoint from NIA's issuer at runtime — you never hard-code the authorization, token, or JWKS URLs. Fetch the service configuration once, typically in your Application class or login screen.
import android.net.Uri
import net.openid.appauth.AuthorizationServiceConfiguration
object AuthManager {
fun fetchServiceConfig(
onReady: (AuthorizationServiceConfiguration?, Exception?) -> Unit
) {
AuthorizationServiceConfiguration.fetchFromIssuer(
Uri.parse("https://dev-api.niaauth.com")
) { config, ex ->
// config is ready — cache it, or fall back to a hard-coded
// AuthorizationServiceConfiguration if you need to work offline.
onReady(config, ex)
}
}
}AuthorizationServiceConfiguration for the life of the process instead of refetching on every login.05Environment Variables
Native apps ship no secret, so there is nothing sensitive to protect at runtime — but keep the issuer, client ID, and redirect URI in one place so you don't hard-code them across the codebase. Gradle build config fields work well here.
android {
defaultConfig {
buildConfigField "String", "NIA_ISSUER", "\"https://dev-api.niaauth.com\""
buildConfigField "String", "NIA_CLIENT_ID", "\"your-client-id\""
buildConfigField "String", "NIA_REDIRECT_URI", "\"com.example.app:/oauth2redirect\""
}
buildFeatures {
buildConfig true
}
}client_secret here — a native app cannot keep a secret secret. Register this client as public in the NIA dashboard.06Register OAuth Client
In the NIA dashboard, create the client your Android app will authenticate as:
- Open Dashboard → Clients → New client
- Client type: Native / Public (no client secret is issued)
- Grant type:
authorization_codewith PKCE required - Allowed scopes:
openidemailprofileoffline_access - Add your redirect URI (next step)
- Copy the
client_id— there is no secret to copy
07Redirect URI
Native apps use a custom URI scheme instead of an HTTPS redirect. Pick a scheme unique to your app — typically your reversed application ID — and register the exact same value on the NIA client.
com.example.app:/oauth2redirectDeclare the scheme as a manifest placeholder so AppAuth's RedirectUriReceiverActivity can capture the redirect, then reference it in the manifest.
android {
defaultConfig {
manifestPlaceholders = [
appAuthRedirectScheme: "com.example.app"
]
}
}manifestPlaceholders, the manifest, and the redirect_uri registered on the NIA client must all match exactly — this is the most common integration failure.08Issuer
The issuer is the single URL AppAuth needs to discover every endpoint below.
Issuer: https://dev-api.niaauth.com
Discovery: https://dev-api.niaauth.com/.well-known/openid-configuration
Authorization: https://dev-api.niaauth.com/oauth/authorize
Token: https://dev-api.niaauth.com/oauth/token
UserInfo: https://dev-api.niaauth.com/oauth/userinfo
JWKS: https://dev-api.niaauth.com/oauth/jwks.json09Authorization Flow
Build an AuthorizationRequest from the discovered config and launch it in a Custom Tab. AppAuth generates the PKCE code_verifier/code_challenge pair and the state value automatically — you never handle them yourself.
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationService
import net.openid.appauth.ResponseTypeValues
private const val RC_AUTH = 100
fun startLogin(
activity: android.app.Activity,
config: net.openid.appauth.AuthorizationServiceConfiguration
) {
val clientId = BuildConfig.NIA_CLIENT_ID
val redirectUri = android.net.Uri.parse(BuildConfig.NIA_REDIRECT_URI)
val authRequest = AuthorizationRequest.Builder(
config, clientId, ResponseTypeValues.CODE, redirectUri
)
.setScope("openid email profile offline_access")
.build() // PKCE (S256) code_verifier/challenge generated internally
val authService = AuthorizationService(activity)
val intent = authService.getAuthorizationRequestIntent(authRequest)
activity.startActivityForResult(intent, RC_AUTH)
}10Token Exchange
When the Custom Tab redirects back to RedirectUriReceiverActivity, handle the result in onActivityResult and exchange the authorization code for tokens. AppAuth automatically sends the matching PKCE code_verifier — there is no manual PKCE bookkeeping.
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthState
override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != RC_AUTH || data == null) return
val resp = AuthorizationResponse.fromIntent(data)
val ex = AuthorizationException.fromIntent(data)
if (resp == null) {
// ex describes what went wrong (access_denied, invalid_request, ...)
return
}
val authService = AuthorizationService(this)
authService.performTokenRequest(resp.createTokenExchangeRequest()) { tokenResp, tokenEx ->
if (tokenResp != null) {
val accessToken = tokenResp.accessToken
val idToken = tokenResp.idToken
val refreshToken = tokenResp.refreshToken
// persist via AuthState — see Session Management
} else {
// tokenEx describes the failure (invalid_grant, network error, ...)
}
}
}11JWKS Validation
NIA signs ID tokens with RS256. AppAuth validates the ID token's signature against NIA's JWKS (fetched via discovery), plus iss, aud, exp, and nonce, before handing you the token response. Persist the result in an AuthState so validated claims travel with it.
val authState = AuthState(resp, ex)
authState.update(tokenResp, tokenEx)
// authState.isAuthorized is true only once tokens have been validated
// and stored. Persist authState.jsonSerializeString() — see Session Management.kid during validation, so rotation needs no code change on your side.12Session Management
Persist AppAuth's AuthState — it carries the access token, ID token, refresh token, and service configuration — in EncryptedSharedPreferences so a token dump from a rooted device doesn't leak credentials. Restore it on app launch.
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import net.openid.appauth.AuthState
class AuthStateStore(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"nia_auth_state",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun read(): AuthState =
prefs.getString("state", null)?.let { AuthState.jsonDeserialize(it) } ?: AuthState()
fun write(state: AuthState) {
prefs.edit().putString("state", state.jsonSerializeString()).apply()
}
}13Refresh Tokens
Because you requested offline_access, NIA returns a refresh token. Don't refresh manually — call performActionWithFreshTokens, which checks expiry and silently refreshes only when needed, then rewrites the updated AuthState to storage.
fun withFreshTokens(
context: android.content.Context,
store: AuthStateStore,
onReady: (accessToken: String?, idToken: String?) -> Unit
) {
val authState = store.read()
val authService = AuthorizationService(context)
authState.performActionWithFreshTokens(authService) { accessToken, idToken, ex ->
if (ex != null) {
// refresh_token invalid/expired — require the user to sign in again
return@performActionWithFreshTokens
}
store.write(authState) // AuthState mutates in place; persist the refreshed tokens
onReady(accessToken, idToken)
}
}14Logout
Send the user to NIA's end-session endpoint so the server-side session ends too, then clear the locally persisted AuthState. Register your post_logout_redirect_uri on the client.
import net.openid.appauth.EndSessionRequest
import net.openid.appauth.AuthorizationService
private const val RC_END_SESSION = 200
fun startLogout(
activity: android.app.Activity,
config: net.openid.appauth.AuthorizationServiceConfiguration,
store: AuthStateStore
) {
val authState = store.read()
val endSessionRequest = EndSessionRequest.Builder(config)
.setIdTokenHint(authState.idToken)
.setPostLogoutRedirectUri(android.net.Uri.parse(BuildConfig.NIA_REDIRECT_URI))
.build()
val authService = AuthorizationService(activity)
val intent = authService.getEndSessionRequestIntent(endSessionRequest)
activity.startActivityForResult(intent, RC_END_SESSION)
store.write(AuthState()) // clear local state immediately; don't wait on the redirect
}15Deployment
Before shipping to production:
- Register a production custom-scheme redirect URI on the client (a distinct scheme per app variant/flavor avoids collisions on shared devices)
- Confirm the client is public — no
client_secretshould exist for this client - Ship the
appAuthRedirectSchememanifest placeholder in your releasebuild.gradle - Store
AuthStateonly inEncryptedSharedPreferences, never plainSharedPreferences - Enable ProGuard/R8 rules for AppAuth if you use code shrinking
android {
buildTypes {
release {
manifestPlaceholders = [
appAuthRedirectScheme: "com.example.app"
]
minifyEnabled true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
}16Troubleshooting
- redirect_uri_mismatch — the custom scheme in
manifestPlaceholders, the manifest intent-filter, and the client's registeredredirect_urimust match exactly. - appAuthRedirectScheme not found — the placeholder is missing from
build.gradle, soRedirectUriReceiverActivitynever gets an intent-filter and the browser can't return to the app. - invalid_client — the client was registered as confidential instead of public/native. A native app must never send a
client_secret. - Custom Tabs not available — no browser supporting Custom Tabs is installed; AppAuth falls back to a full WebView, which NIA does not treat as a trusted user-agent for login.
- invalid_grant / PKCE failed — the
AuthorizationServiceinstance used for the token exchange differs from the one that built the request, or the process was killed between redirect and exchange. - ID-token signature — usually device clock skew; ensure automatic date/time is enabled.