From fa78ee31b4eb360c585dd17f897c0cec5b91800e Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 28 Apr 2026 15:57:16 +0200 Subject: [PATCH] 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 --- .../ulfrx/recipe/auth/OidcClient.android.kt | 279 ++++++++++++++++++ .../auth/SecureAuthStateStore.android.kt | 50 ++++ 2 files changed, 329 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt create mode 100644 composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt diff --git a/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt b/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt new file mode 100644 index 0000000..00832a1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt @@ -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().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 + } +} diff --git a/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt b/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt new file mode 100644 index 0000000..238acd2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt @@ -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().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" + } +}