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.

~25 minAdvancedAppAuth-Android
Try OAuth Flow
Integration progress
0/16

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 AuthState store

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_id only, 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"
}
Sync Gradle after adding the dependency. No other NIA-specific SDK is required.

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.

AuthManager.kt
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)
        }
    }
}
Discovery is a network call. Cache the resulting 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.

app/build.gradle (config)
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
    }
}
Never add a 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_code with PKCE required
  • Allowed scopes: openid email profile offline_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.

redirect URI
com.example.app:/oauth2redirect

Declare 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"
        ]
    }
}
The scheme in 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 & endpoints
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.json

09Authorization 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.

LoginActivity.kt
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.

LoginActivity.kt
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.

AuthManager.kt
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.
Keys rotate on NIA's side. AppAuth refetches JWKS by 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.

AuthStateStore.kt
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.

AuthManager.kt
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.

LogoutActivity.kt
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_secret should exist for this client
  • Ship the appAuthRedirectScheme manifest placeholder in your release build.gradle
  • Store AuthState only in EncryptedSharedPreferences, never plain SharedPreferences
  • Enable ProGuard/R8 rules for AppAuth if you use code shrinking
app/build.gradle (release)
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 registered redirect_uri must match exactly.
  • appAuthRedirectScheme not found — the placeholder is missing from build.gradle, so RedirectUriReceiverActivity never 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 AuthorizationService instance 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.