feat(02-04): implement Android AppAuth OIDC client

- Add Android AppAuth login, fresh-token refresh, and end-session logout
- Add Android secure AuthState store actual required for Android compilation
This commit is contained in:
2026-04-28 15:57:16 +02:00
parent a94f803ca6
commit fa78ee31b4
2 changed files with 329 additions and 0 deletions

View File

@@ -0,0 +1,279 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import dev.ulfrx.recipe.shared.Constants
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.EndSessionRequest
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import org.koin.core.context.GlobalContext
actual class OidcClient {
private val context: Context
get() = GlobalContext.get().get<Context>().applicationContext
actual suspend fun login(): OidcResult {
val configuration = fetchConfiguration()
?: return OidcResult.NetworkError
val request =
AuthorizationRequest
.Builder(
configuration,
Constants.OIDC_CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(Constants.OIDC_REDIRECT_URI),
)
.setScopes("openid", "profile", "email", "offline_access")
.build()
val service = AuthorizationService(context)
return try {
when (val authorization = service.performAuthorization(request)) {
is AuthorizationOutcome.Success -> exchangeCode(service, authorization.response)
is AuthorizationOutcome.Cancelled -> OidcResult.Cancelled
is AuthorizationOutcome.Error -> authorization.exception.toOidcError()
}
} finally {
service.dispose()
}
}
actual suspend fun refresh(authStateJson: String): OidcResult {
val authState =
runCatching { AuthState.jsonDeserialize(authStateJson) }
.getOrElse { return OidcResult.AuthError("Invalid AuthState JSON", it) }
val service = AuthorizationService(context)
return try {
service.freshTokens(authState)
} finally {
service.dispose()
}
}
actual suspend fun logout(authStateJson: String) {
val authState = runCatching { AuthState.jsonDeserialize(authStateJson) }.getOrNull() ?: return
val configuration = authState.authorizationServiceConfiguration ?: return
if (configuration.endSessionEndpoint == null) return
val request =
EndSessionRequest
.Builder(configuration)
.setIdTokenHint(authState.idToken)
.setPostLogoutRedirectUri(Uri.parse(Constants.OIDC_REDIRECT_URI))
.build()
val service = AuthorizationService(context)
try {
service.performEndSession(request)
} finally {
service.dispose()
}
}
private suspend fun fetchConfiguration(): AuthorizationServiceConfiguration? =
suspendCancellableCoroutine { continuation ->
AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse(Constants.OIDC_ISSUER)) { configuration, exception ->
if (!continuation.isActive) return@fetchFromIssuer
when {
configuration != null -> continuation.resume(configuration)
exception != null && exception.isNetworkFailure() -> continuation.resume(null)
else -> continuation.resume(null)
}
}
}
private suspend fun exchangeCode(
service: AuthorizationService,
authorizationResponse: AuthorizationResponse,
): OidcResult =
suspendCancellableCoroutine { continuation ->
val authState = AuthState(authorizationResponse, null)
service.performTokenRequest(authorizationResponse.createTokenExchangeRequest()) { tokenResponse, exception ->
if (!continuation.isActive) return@performTokenRequest
when {
exception != null -> continuation.resume(exception.toOidcError())
tokenResponse == null -> continuation.resume(OidcResult.AuthError("Token exchange returned no response"))
else -> {
authState.update(tokenResponse, null)
continuation.resume(authState.toSuccess(tokenResponse))
}
}
}
continuation.invokeOnCancellation { service.dispose() }
}
private suspend fun AuthorizationService.freshTokens(authState: AuthState): OidcResult =
suspendCancellableCoroutine { continuation ->
authState.performActionWithFreshTokens(this) { accessToken, idToken, exception ->
if (!continuation.isActive) return@performActionWithFreshTokens
when {
exception != null -> continuation.resume(exception.toOidcError())
accessToken == null -> continuation.resume(OidcResult.AuthError("Refresh returned no access token"))
else ->
continuation.resume(
OidcResult.Success(
authStateJson = authState.jsonSerializeString(),
accessToken = accessToken,
idToken = idToken,
expiresAtEpochMillis = authState.accessTokenExpirationTime ?: 0L,
),
)
}
}
continuation.invokeOnCancellation { dispose() }
}
private suspend fun AuthorizationService.performAuthorization(
request: AuthorizationRequest,
): AuthorizationOutcome =
suspendCancellableCoroutine { continuation ->
val appContext = context
val action = "${appContext.packageName}.auth.OIDC_AUTH_RESULT.${System.nanoTime()}"
val filter = IntentFilter(action)
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
appContext.unregisterReceiver(this)
if (!continuation.isActive) return
val exception = AuthorizationException.fromIntent(intent)
val response = AuthorizationResponse.fromIntent(intent)
continuation.resume(
when {
exception != null && exception.isCancellation() -> AuthorizationOutcome.Cancelled
exception != null -> AuthorizationOutcome.Error(exception)
response != null -> AuthorizationOutcome.Success(response)
else -> AuthorizationOutcome.Cancelled
},
)
}
}
appContext.registerPrivateReceiver(receiver, filter)
val completionIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode(),
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
val cancelIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode() + 1,
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
continuation.invokeOnCancellation {
runCatching { appContext.unregisterReceiver(receiver) }
dispose()
}
performAuthorizationRequest(request, completionIntent, cancelIntent)
}
private suspend fun AuthorizationService.performEndSession(request: EndSessionRequest) =
suspendCancellableCoroutine { continuation ->
val appContext = context
val action = "${appContext.packageName}.auth.OIDC_END_SESSION_RESULT.${System.nanoTime()}"
val filter = IntentFilter(action)
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
appContext.unregisterReceiver(this)
if (continuation.isActive) continuation.resume(Unit)
}
}
appContext.registerPrivateReceiver(receiver, filter)
val completionIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode(),
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
val cancelIntent =
PendingIntent.getBroadcast(
appContext,
action.hashCode() + 1,
Intent(action).setPackage(appContext.packageName),
pendingIntentFlags(),
)
continuation.invokeOnCancellation {
runCatching { appContext.unregisterReceiver(receiver) }
dispose()
}
performEndSessionRequest(request, completionIntent, cancelIntent)
}
private fun AuthState.toSuccess(tokenResponse: TokenResponse): OidcResult.Success =
OidcResult.Success(
authStateJson = jsonSerializeString(),
accessToken = tokenResponse.accessToken.orEmpty(),
idToken = tokenResponse.idToken,
expiresAtEpochMillis = tokenResponse.accessTokenExpirationTime ?: 0L,
)
private fun AuthorizationException.toOidcError(): OidcResult =
when {
isCancellation() -> OidcResult.Cancelled
isNetworkFailure() -> OidcResult.NetworkError
else -> OidcResult.AuthError("OIDC request failed", this)
}
private fun AuthorizationException.isCancellation(): Boolean =
type == AuthorizationException.TYPE_GENERAL_ERROR &&
(code == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW.code ||
code == AuthorizationException.GeneralErrors.PROGRAM_CANCELED_AUTH_FLOW.code)
private fun AuthorizationException.isNetworkFailure(): Boolean =
type == AuthorizationException.TYPE_GENERAL_ERROR &&
(code == AuthorizationException.GeneralErrors.NETWORK_ERROR.code ||
code == AuthorizationException.GeneralErrors.SERVER_ERROR.code)
private fun Context.registerPrivateReceiver(
receiver: BroadcastReceiver,
filter: IntentFilter,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("DEPRECATION")
registerReceiver(receiver, filter)
}
}
private fun pendingIntentFlags(): Int =
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
private sealed interface AuthorizationOutcome {
data class Success(val response: AuthorizationResponse) : AuthorizationOutcome
data class Error(val exception: AuthorizationException) : AuthorizationOutcome
data object Cancelled : AuthorizationOutcome
}
}

View File

@@ -0,0 +1,50 @@
@file:Suppress("DEPRECATION", "EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.koin.core.context.GlobalContext
actual class SecureAuthStateStore {
private val preferences by lazy {
val appContext = GlobalContext.get().get<Context>().applicationContext
val masterKey =
MasterKey
.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// AndroidX Security Crypto is deprecated, but AUTH-02 explicitly requires
// EncryptedSharedPreferences in v1; this abstraction contains that debt.
EncryptedSharedPreferences.create(
appContext,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
actual fun read(): String? = preferences.getString(KEY_AUTH_STATE_JSON, null)
actual fun write(authStateJson: String) {
preferences
.edit()
.putString(KEY_AUTH_STATE_JSON, authStateJson)
.apply()
}
actual fun clear() {
preferences
.edit()
.remove(KEY_AUTH_STATE_JSON)
.apply()
}
private companion object {
const val FILE_NAME = "recipe_auth_state"
const val KEY_AUTH_STATE_JSON = "auth_state_json"
}
}