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