From ac9fc6141084cd0c8b5aa3ae95d92919eb9ce49d Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 28 Apr 2026 16:14:04 +0200 Subject: [PATCH] feat(02-05): implement iOS AppAuth client - Add AppAuth login, refresh, logout actual - Add iOS Keychain auth state store to unblock native compile - Add iOS Podfile AppAuth integration --- .../dev/ulfrx/recipe/auth/OidcClient.ios.kt | 231 ++++++++++++++++++ .../recipe/auth/SecureAuthStateStore.ios.kt | 33 +++ iosApp/Podfile | 7 + 3 files changed, 271 insertions(+) create mode 100644 composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt create mode 100644 iosApp/Podfile diff --git a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt new file mode 100644 index 0000000..c076d3e --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt @@ -0,0 +1,231 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package dev.ulfrx.recipe.auth + +import cocoapods.AppAuth.OIDAuthState +import cocoapods.AppAuth.OIDAuthorizationRequest +import cocoapods.AppAuth.OIDAuthorizationService +import cocoapods.AppAuth.OIDEndSessionRequest +import cocoapods.AppAuth.OIDErrorCodeNetworkError +import cocoapods.AppAuth.OIDErrorCodeProgramCanceledAuthorizationFlow +import cocoapods.AppAuth.OIDErrorCodeUserCanceledAuthorizationFlow +import cocoapods.AppAuth.OIDExternalUserAgentIOS +import cocoapods.AppAuth.OIDExternalUserAgentSessionProtocol +import cocoapods.AppAuth.OIDResponseTypeCode +import dev.ulfrx.recipe.shared.Constants +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.Foundation.NSData +import platform.Foundation.NSDate +import platform.Foundation.NSError +import platform.Foundation.NSKeyedArchiver +import platform.Foundation.NSKeyedUnarchiver +import platform.Foundation.NSURL +import platform.Foundation.base64EncodedStringWithOptions +import platform.Foundation.create +import platform.Foundation.timeIntervalSince1970 +import platform.UIKit.UIApplication +import platform.UIKit.UIViewController +import kotlin.coroutines.resume +import kotlin.math.roundToLong + +@OptIn(ExperimentalForeignApi::class) +actual class OidcClient { + actual suspend fun login(): OidcResult { + val configuration = discoverConfiguration() ?: return OidcResult.NetworkError + val request = + OIDAuthorizationRequest( + configuration = configuration, + clientId = Constants.OIDC_CLIENT_ID, + scopes = listOf("openid", "profile", "email", "offline_access"), + redirectURL = NSURL(string = Constants.OIDC_REDIRECT_URI), + responseType = OIDResponseTypeCode ?: "code", + additionalParameters = null, + ) + + val presenter = + topViewController() + ?: return OidcResult.AuthError("Unable to find an iOS view controller for OIDC login") + + return suspendCancellableCoroutine { continuation -> + val session = + OIDAuthState.authStateByPresentingAuthorizationRequest( + authorizationRequest = request, + externalUserAgent = OIDExternalUserAgentIOS(presentingViewController = presenter), + callback = { authState, error -> + IosAppAuthBridge.clearCurrentAuthorizationFlow() + if (!continuation.isActive) return@authStateByPresentingAuthorizationRequest + continuation.resume(authState.toOidcResult(error)) + }, + ) + IosAppAuthBridge.currentAuthorizationFlow = session + continuation.invokeOnCancellation { + session.cancel() + IosAppAuthBridge.clearCurrentAuthorizationFlow() + } + } + } + + actual suspend fun refresh(authStateJson: String): OidcResult { + val authState = + deserializeAuthState(authStateJson) + ?: return OidcResult.AuthError("Unable to restore iOS AppAuth state") + + return suspendCancellableCoroutine { continuation -> + authState.performActionWithFreshTokens { accessToken, idToken, error -> + if (!continuation.isActive) return@performActionWithFreshTokens + if (error != null) { + continuation.resume(error.toOidcResult()) + } else { + continuation.resume( + authState.toSuccess( + accessToken = accessToken, + idToken = idToken, + ), + ) + } + } + } + } + + actual suspend fun logout(authStateJson: String) { + val authState = deserializeAuthState(authStateJson) ?: return + val configuration = authState.lastAuthorizationResponse.request.configuration + if (configuration.endSessionEndpoint == null) return + val idTokenHint = + authState.lastTokenResponse?.idToken ?: authState.lastAuthorizationResponse.idToken ?: return + + val request = + OIDEndSessionRequest( + configuration = configuration, + idTokenHint = idTokenHint, + postLogoutRedirectURL = NSURL(string = Constants.OIDC_REDIRECT_URI), + additionalParameters = null, + ) + + val presenter = + topViewController() + ?: return + + suspendCancellableCoroutine { continuation -> + val session = + OIDAuthorizationService.presentEndSessionRequest( + request = request, + externalUserAgent = OIDExternalUserAgentIOS(presentingViewController = presenter), + callback = { _, _ -> + IosAppAuthBridge.clearCurrentAuthorizationFlow() + if (continuation.isActive) continuation.resume(Unit) + }, + ) + IosAppAuthBridge.currentAuthorizationFlow = session + continuation.invokeOnCancellation { + session.cancel() + IosAppAuthBridge.clearCurrentAuthorizationFlow() + } + } + } +} + +@OptIn(ExperimentalForeignApi::class) +object IosAppAuthBridge { + internal var currentAuthorizationFlow: OIDExternalUserAgentSessionProtocol? = null + + fun resumeExternalUserAgentFlow(urlString: String): Boolean { + if (!urlString.startsWith(Constants.OIDC_REDIRECT_URI)) return false + val url = NSURL(string = urlString) + val consumed = currentAuthorizationFlow?.resumeExternalUserAgentFlowWithURL(url) ?: false + if (consumed) clearCurrentAuthorizationFlow() + return consumed + } + + internal fun clearCurrentAuthorizationFlow() { + currentAuthorizationFlow = null + } +} + +@OptIn(ExperimentalForeignApi::class) +private suspend fun discoverConfiguration() = + suspendCancellableCoroutine { continuation -> + OIDAuthorizationService.discoverServiceConfigurationForIssuer( + issuerURL = NSURL(string = Constants.OIDC_ISSUER), + completion = { configuration, _ -> + if (continuation.isActive) continuation.resume(configuration) + }, + ) + } + +@OptIn(ExperimentalForeignApi::class) +private fun topViewController(): UIViewController? { + val root = UIApplication.sharedApplication.keyWindow?.rootViewController + var current = root + while (current?.presentedViewController != null) { + current = current.presentedViewController + } + return current +} + +@OptIn(ExperimentalForeignApi::class) +private fun OIDAuthState?.toOidcResult(error: NSError?): OidcResult { + if (error != null) return error.toOidcResult() + return this?.toSuccess() ?: OidcResult.AuthError("OIDC login completed without an auth state") +} + +@OptIn(ExperimentalForeignApi::class) +private fun NSError.toOidcResult(): OidcResult = + when (code) { + OIDErrorCodeUserCanceledAuthorizationFlow, + OIDErrorCodeProgramCanceledAuthorizationFlow, + -> OidcResult.Cancelled + OIDErrorCodeNetworkError -> OidcResult.NetworkError + else -> OidcResult.AuthError(localizedDescription) + } + +@OptIn(ExperimentalForeignApi::class) +private fun OIDAuthState.toSuccess( + accessToken: String? = lastTokenResponse?.accessToken ?: lastAuthorizationResponse.accessToken, + idToken: String? = lastTokenResponse?.idToken ?: lastAuthorizationResponse.idToken, +): OidcResult { + val token = accessToken ?: return OidcResult.AuthError("OIDC auth state has no access token") + return OidcResult.Success( + authStateJson = serializeAuthState(), + accessToken = token, + idToken = idToken, + expiresAtEpochMillis = lastTokenResponse?.accessTokenExpirationDate.toEpochMillis(), + ) +} + +@OptIn(ExperimentalForeignApi::class) +private fun NSDate?.toEpochMillis(): Long = + this?.timeIntervalSince1970?.times(1_000)?.roundToLong() ?: 0L + +@OptIn(ExperimentalForeignApi::class) +private fun OIDAuthState.serializeAuthState(): String { + val data = + NSKeyedArchiver.archivedDataWithRootObject( + `object` = this, + requiringSecureCoding = true, + error = null, + ) ?: error("Unable to archive iOS AppAuth state") + val archive = data.base64EncodedStringWithOptions(0u) + return """{"format":"ios-nskeyedarchive-v1","archive":"$archive"}""" +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private fun deserializeAuthState(value: String): OIDAuthState? { + val archive = extractArchive(value) ?: return null + val data = NSData.create(base64EncodedString = archive, options = 0u) ?: return null + return NSKeyedUnarchiver.unarchivedObjectOfClass( + cls = OIDAuthState.`class`() ?: return null, + fromData = data, + error = null, + ) as? OIDAuthState +} + +@OptIn(ExperimentalForeignApi::class) +private fun extractArchive(value: String): String? { + return Regex(""""archive":"([^"]+)"""") + .find(value) + ?.groupValues + ?.getOrNull(1) +} diff --git a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt new file mode 100644 index 0000000..154018f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt @@ -0,0 +1,33 @@ +@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") + +package dev.ulfrx.recipe.auth + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.ExperimentalSettingsImplementation +import com.russhwolf.settings.KeychainSettings +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + +@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class, ExperimentalForeignApi::class) +actual class SecureAuthStateStore { + private val settings = + KeychainSettings( + kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ) + + actual fun read(): String? = + settings.getStringOrNull(authStateKey) + + actual fun write(authStateJson: String) { + settings.putString(authStateKey, authStateJson) + } + + actual fun clear() { + settings.remove(authStateKey) + } + + private companion object { + const val authStateKey = "dev.ulfrx.recipe.auth.appauth-state" + } +} diff --git a/iosApp/Podfile b/iosApp/Podfile new file mode 100644 index 0000000..0e8ec18 --- /dev/null +++ b/iosApp/Podfile @@ -0,0 +1,7 @@ +platform :ios, '15.0' + +target 'iosApp' do + use_frameworks! + + pod 'AppAuth' +end