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:
@@ -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)
|
||||
}
|
||||
@@ -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
7
iosApp/Podfile
Normal file
@@ -0,0 +1,7 @@
|
||||
platform :ios, '15.0'
|
||||
|
||||
target 'iosApp' do
|
||||
use_frameworks!
|
||||
|
||||
pod 'AppAuth'
|
||||
end
|
||||
Reference in New Issue
Block a user