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
This commit is contained in:
2026-04-28 16:14:04 +02:00
parent 8d1c34c2f6
commit ac9fc61410
3 changed files with 271 additions and 0 deletions

View File

@@ -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<Unit> { 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<cocoapods.AppAuth.OIDServiceConfiguration?> { 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)
}

View File

@@ -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"
}
}

7
iosApp/Podfile Normal file
View File

@@ -0,0 +1,7 @@
platform :ios, '15.0'
target 'iosApp' do
use_frameworks!
pod 'AppAuth'
end