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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user