Drop cocoapods
This commit is contained in:
@@ -7,17 +7,12 @@ plugins {
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.composeHotReload)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
// CocoaPods is shipped inside the Kotlin Gradle plugin already on the classpath via
|
||||
// `recipe.kotlin.multiplatform`. Applying via `alias(libs.plugins.kotlinCocoapods)`
|
||||
// would request a fresh version and fail with "already on the classpath", so we
|
||||
// apply it by id only. The catalog still owns the shared Kotlin version.
|
||||
id("org.jetbrains.kotlin.native.cocoapods")
|
||||
id("recipe.quality")
|
||||
}
|
||||
|
||||
// Top-level project version is required by the Kotlin CocoaPods plugin when no explicit
|
||||
// `version` is set inside the `cocoapods { ... }` block. Mirrors `server/build.gradle.kts`
|
||||
// — Gradle artifact metadata only, NOT a library/plugin pin (per `verify-no-version-literals.sh`).
|
||||
// `group` is referenced by Compose Resources package naming — the
|
||||
// `compose.resources { packageOfResClass }` block below pins the historical package
|
||||
// regardless, but keep `group` set explicitly. Gradle artifact metadata only.
|
||||
group = "dev.ulfrx.recipe"
|
||||
version = "1.0.0"
|
||||
|
||||
@@ -66,26 +61,6 @@ android {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
// The Kotlin CocoaPods plugin (D-01) configures the iOS framework on the iOS targets
|
||||
// declared by `recipe.kotlin.multiplatform`. `baseName = "ComposeApp"` / `isStatic = true`
|
||||
// keep existing Swift `import ComposeApp` working. The AppAuth iOS pod version comes
|
||||
// from the version catalog so this build file stays free of literal pins.
|
||||
cocoapods {
|
||||
summary = "Recipe Compose Multiplatform shared framework"
|
||||
homepage = "https://github.com/ulfrxdev/recipe"
|
||||
ios.deploymentTarget = "15.0"
|
||||
podfile = project.file("../iosApp/Podfile")
|
||||
framework {
|
||||
baseName = "ComposeApp"
|
||||
isStatic = true
|
||||
}
|
||||
pod("AppAuth") {
|
||||
version =
|
||||
libs.versions.appauth.ios
|
||||
.get()
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(project.dependencies.platform(libs.koin.bom))
|
||||
@@ -101,7 +76,9 @@ kotlin {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation(projects.shared)
|
||||
// `api` so `:shared` types (notably `Constants`) flow through to the
|
||||
// exported ObjC framework headers — the iOS Swift bridge needs them.
|
||||
api(projects.shared)
|
||||
|
||||
// Phase 2: Ktor client + serialization + secure settings (D-13, D-16, D-17).
|
||||
// The MPP variant of `ktor-serialization-kotlinx-json` is required here; the
|
||||
@@ -135,8 +112,9 @@ kotlin {
|
||||
implementation(libs.ktor.clientOkhttp)
|
||||
}
|
||||
iosMain.dependencies {
|
||||
// Phase 2 iOS: Darwin engine for Ktor; AppAuth-iOS arrives via the
|
||||
// CocoaPods block above so the shared framework links it directly.
|
||||
// Phase 2 iOS: Darwin engine for Ktor. AppAuth-iOS is delivered via
|
||||
// SwiftPM in iosApp.xcodeproj and consumed through a Swift bridge —
|
||||
// no Kotlin-side AppAuth dependency (DECISION-drop-cocoapods, 2026-04-28).
|
||||
implementation(libs.ktor.clientDarwin)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
@@ -155,11 +133,10 @@ dependencies {
|
||||
debugImplementation(libs.compose.uiTooling)
|
||||
}
|
||||
|
||||
// Adding `group = "dev.ulfrx.recipe"` (required by the Kotlin CocoaPods plugin to render
|
||||
// the podspec) shifts the Compose Resources `Res` class package from
|
||||
// `group = "dev.ulfrx.recipe"` shifts the Compose Resources `Res` class package from
|
||||
// `recipe.composeapp.generated.resources` to `dev.ulfrx.recipe.composeapp.generated.resources`,
|
||||
// breaking the Phase 1 `App.kt` import. Lock the historical package so this plan's wiring
|
||||
// changes don't cascade into UI code; Plan 02-04+ replaces `App.kt`'s template body anyway.
|
||||
// breaking the Phase 1 `App.kt` import. Lock the historical package so module-naming
|
||||
// changes don't cascade into UI code.
|
||||
compose.resources {
|
||||
packageOfResClass = "recipe.composeapp.generated.resources"
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
@@ -22,6 +21,7 @@ import net.openid.appauth.EndSessionRequest
|
||||
import net.openid.appauth.ResponseTypeValues
|
||||
import net.openid.appauth.TokenResponse
|
||||
import org.koin.core.context.GlobalContext
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
actual class OidcClient {
|
||||
private val context: Context
|
||||
@@ -41,8 +41,7 @@ actual class OidcClient {
|
||||
Constants.OIDC_CLIENT_ID,
|
||||
ResponseTypeValues.CODE,
|
||||
Uri.parse(Constants.OIDC_REDIRECT_URI),
|
||||
)
|
||||
.setScopes("openid", "profile", "email", "offline_access")
|
||||
).setScopes("openid", "profile", "email", "offline_access")
|
||||
.build()
|
||||
|
||||
val service = AuthorizationService(context)
|
||||
@@ -95,14 +94,21 @@ actual class OidcClient {
|
||||
AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse(Constants.OIDC_ISSUER)) { configuration, exception ->
|
||||
if (!continuation.isActive) return@fetchFromIssuer
|
||||
when {
|
||||
configuration != null -> continuation.resume(ConfigurationOutcome.Success(configuration))
|
||||
exception != null -> continuation.resume(ConfigurationOutcome.Error(exception))
|
||||
else ->
|
||||
configuration != null -> {
|
||||
continuation.resume(ConfigurationOutcome.Success(configuration))
|
||||
}
|
||||
|
||||
exception != null -> {
|
||||
continuation.resume(ConfigurationOutcome.Error(exception))
|
||||
}
|
||||
|
||||
else -> {
|
||||
continuation.resume(
|
||||
ConfigurationOutcome.Error(
|
||||
AuthorizationException.GeneralErrors.INVALID_DISCOVERY_DOCUMENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,10 +122,18 @@ actual class OidcClient {
|
||||
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"))
|
||||
tokenResponse.accessToken.isNullOrBlank() ->
|
||||
exception != null -> {
|
||||
continuation.resume(exception.toOidcError())
|
||||
}
|
||||
|
||||
tokenResponse == null -> {
|
||||
continuation.resume(OidcResult.AuthError("Token exchange returned no response"))
|
||||
}
|
||||
|
||||
tokenResponse.accessToken.isNullOrBlank() -> {
|
||||
continuation.resume(OidcResult.AuthError("Token exchange returned no access token"))
|
||||
}
|
||||
|
||||
else -> {
|
||||
authState.update(tokenResponse, null)
|
||||
continuation.resume(authState.toSuccess(tokenResponse))
|
||||
@@ -134,9 +148,15 @@ actual class OidcClient {
|
||||
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 ->
|
||||
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(),
|
||||
@@ -145,21 +165,23 @@ actual class OidcClient {
|
||||
expiresAtEpochMillis = authState.accessTokenExpirationTime ?: 0L,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
continuation.invokeOnCancellation { dispose() }
|
||||
}
|
||||
|
||||
private suspend fun AuthorizationService.performAuthorization(
|
||||
request: AuthorizationRequest,
|
||||
): AuthorizationOutcome =
|
||||
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) {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
appContext.unregisterReceiver(this)
|
||||
if (!continuation.isActive) return
|
||||
|
||||
@@ -208,7 +230,10 @@ actual class OidcClient {
|
||||
val filter = IntentFilter(action)
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
appContext.unregisterReceiver(this)
|
||||
if (continuation.isActive) continuation.resume(Unit)
|
||||
}
|
||||
@@ -256,13 +281,17 @@ actual class OidcClient {
|
||||
|
||||
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)
|
||||
(
|
||||
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)
|
||||
(
|
||||
code == AuthorizationException.GeneralErrors.NETWORK_ERROR.code ||
|
||||
code == AuthorizationException.GeneralErrors.SERVER_ERROR.code
|
||||
)
|
||||
|
||||
private fun Context.registerPrivateReceiver(
|
||||
receiver: BroadcastReceiver,
|
||||
@@ -276,20 +305,27 @@ actual class OidcClient {
|
||||
}
|
||||
}
|
||||
|
||||
private fun pendingIntentFlags(): Int =
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
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 Success(
|
||||
val response: AuthorizationResponse,
|
||||
) : AuthorizationOutcome
|
||||
|
||||
data class Error(val exception: AuthorizationException) : AuthorizationOutcome
|
||||
data class Error(
|
||||
val exception: AuthorizationException,
|
||||
) : AuthorizationOutcome
|
||||
|
||||
data object Cancelled : AuthorizationOutcome
|
||||
}
|
||||
|
||||
private sealed interface ConfigurationOutcome {
|
||||
data class Success(val configuration: AuthorizationServiceConfiguration) : ConfigurationOutcome
|
||||
data class Success(
|
||||
val configuration: AuthorizationServiceConfiguration,
|
||||
) : ConfigurationOutcome
|
||||
|
||||
data class Error(val exception: AuthorizationException) : ConfigurationOutcome
|
||||
data class Error(
|
||||
val exception: AuthorizationException,
|
||||
) : ConfigurationOutcome
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,7 @@ class AuthSession(
|
||||
object : OidcClientGateway {
|
||||
override suspend fun login(): OidcResult = oidcClient.login()
|
||||
|
||||
override suspend fun refresh(authStateJson: String): OidcResult =
|
||||
oidcClient.refresh(authStateJson)
|
||||
override suspend fun refresh(authStateJson: String): OidcResult = oidcClient.refresh(authStateJson)
|
||||
|
||||
override suspend fun logout(authStateJson: String) {
|
||||
oidcClient.logout(authStateJson)
|
||||
@@ -85,6 +84,7 @@ class AuthSession(
|
||||
|
||||
when (val refreshResult = oidcClient.refresh(storedJson)) {
|
||||
is OidcResult.Success -> authenticate(refreshResult)
|
||||
|
||||
OidcResult.Cancelled,
|
||||
OidcResult.NetworkError,
|
||||
is OidcResult.AuthError,
|
||||
@@ -98,14 +98,17 @@ class AuthSession(
|
||||
authenticate(loginResult)
|
||||
AuthLoginResult.Success
|
||||
}
|
||||
|
||||
OidcResult.Cancelled -> {
|
||||
_state.value = AuthState.Unauthenticated
|
||||
AuthLoginResult.Cancelled
|
||||
}
|
||||
|
||||
OidcResult.NetworkError -> {
|
||||
_state.value = AuthState.Unauthenticated
|
||||
AuthLoginResult.NetworkError
|
||||
}
|
||||
|
||||
is OidcResult.AuthError -> {
|
||||
_state.value = AuthState.Unauthenticated
|
||||
AuthLoginResult.Failed(loginResult.message)
|
||||
@@ -123,26 +126,29 @@ class AuthSession(
|
||||
clearSession()
|
||||
}
|
||||
|
||||
suspend fun getAccessToken(): String? =
|
||||
refreshBearerTokens()?.accessToken
|
||||
suspend fun getAccessToken(): String? = refreshBearerTokens()?.accessToken
|
||||
|
||||
fun currentBearerTokens(): BearerTokens? = currentTokens
|
||||
|
||||
suspend fun refreshBearerTokens(): BearerTokens? {
|
||||
val storedJson = store.read() ?: return null.also {
|
||||
clearSession()
|
||||
}
|
||||
val storedJson =
|
||||
store.read() ?: return null.also {
|
||||
clearSession()
|
||||
}
|
||||
|
||||
return when (val refreshResult = oidcClient.refresh(storedJson)) {
|
||||
is OidcResult.Success -> {
|
||||
persistTokens(refreshResult)
|
||||
currentTokens
|
||||
}
|
||||
|
||||
OidcResult.Cancelled,
|
||||
OidcResult.NetworkError,
|
||||
is OidcResult.AuthError,
|
||||
-> null.also {
|
||||
clearSession()
|
||||
-> {
|
||||
null.also {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ class MeClient(
|
||||
if (!accessToken.isNullOrBlank()) {
|
||||
header(HttpHeaders.Authorization, "Bearer ".plus(accessToken))
|
||||
}
|
||||
}
|
||||
.body<dev.ulfrx.recipe.shared.dto.MeResponse>()
|
||||
}.body<dev.ulfrx.recipe.shared.dto.MeResponse>()
|
||||
.toUser()
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
@file:OptIn(ExperimentalObjCName::class, ExperimentalForeignApi::class)
|
||||
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import platform.UIKit.UIViewController
|
||||
import kotlin.experimental.ExperimentalObjCName
|
||||
import kotlin.native.ObjCName
|
||||
|
||||
/**
|
||||
* iOS auth bridge implemented in Swift on top of AppAuth-iOS.
|
||||
*
|
||||
* AppAuth lives in `iosApp/` (delivered via SwiftPM) since 2026-04-28; Kotlin
|
||||
* code never imports `cocoapods.AppAuth.*`. The Swift implementation is handed
|
||||
* to Kotlin at app startup via [IosAuthBridgeRegistry] and resolved through
|
||||
* Koin in [OidcClient].
|
||||
*
|
||||
* Methods are callback-style on purpose: it gives a stable Obj-C selector for
|
||||
* Swift to override and skips Kotlin/Native suspend-protocol machinery. The
|
||||
* Kotlin caller wraps each call in `suspendCancellableCoroutine`.
|
||||
*/
|
||||
@ObjCName("IosAuthBridge")
|
||||
interface IosAuthBridge {
|
||||
fun login(
|
||||
presentingViewController: UIViewController,
|
||||
completion: (IosAuthBridgeResult) -> Unit,
|
||||
)
|
||||
|
||||
fun refresh(
|
||||
refreshToken: String,
|
||||
completion: (IosAuthBridgeResult) -> Unit,
|
||||
)
|
||||
|
||||
fun endSession(
|
||||
presentingViewController: UIViewController,
|
||||
idTokenHint: String,
|
||||
completion: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Called by `iOSApp.swift` from `onOpenURL` so the Swift side can resume
|
||||
* an in-flight authorization session. Mirrors AppAuth's
|
||||
* `currentAuthorizationFlow.resumeExternalUserAgentFlow(with:)`.
|
||||
*/
|
||||
fun resumeExternalUserAgentFlow(url: String): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum type returned by [IosAuthBridge.login] and [IosAuthBridge.refresh].
|
||||
*
|
||||
* Mapped to [OidcResult] inside [OidcClient]. Kept iOS-local so the bridge can
|
||||
* evolve without touching the common contract.
|
||||
*/
|
||||
sealed class IosAuthBridgeResult {
|
||||
data class Success(
|
||||
val tokens: IosAuthTokens,
|
||||
) : IosAuthBridgeResult()
|
||||
|
||||
data object Cancelled : IosAuthBridgeResult()
|
||||
|
||||
data object NetworkError : IosAuthBridgeResult()
|
||||
|
||||
data class Failed(
|
||||
val message: String,
|
||||
) : IosAuthBridgeResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Token bundle persisted by [SecureAuthStateStore] as JSON.
|
||||
*
|
||||
* Replaces the AppAuth `OIDAuthState` `NSKeyedArchiver` blob — Kotlin now owns
|
||||
* the persistence format end-to-end and can read token expiry locally.
|
||||
*/
|
||||
@Serializable
|
||||
data class IosAuthTokens(
|
||||
val accessToken: String,
|
||||
val refreshToken: String? = null,
|
||||
val idToken: String? = null,
|
||||
val expiresAtEpochMillis: Long = 0L,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hand-off slot from `iOSApp.swift` to Kotlin Koin.
|
||||
*
|
||||
* `iOSApp.init` instantiates the Swift `AuthBridge`, sets it here, then calls
|
||||
* `KoinIosKt.doInitKoin()`. The iOS auth Koin module reads from this slot when
|
||||
* resolving `IosAuthBridge`.
|
||||
*/
|
||||
@ObjCName("IosAuthBridgeRegistry")
|
||||
object IosAuthBridgeRegistry {
|
||||
var instance: IosAuthBridge? = null
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* iOS-only Koin module that exposes the Swift-implemented [IosAuthBridge] to
|
||||
* Kotlin DI. The Swift `AuthBridge` instance is registered in
|
||||
* [IosAuthBridgeRegistry] from `iOSApp.swift` *before* `doInitKoin()` runs, so
|
||||
* `single<IosAuthBridge>` always finds it.
|
||||
*/
|
||||
val iosAuthModule =
|
||||
module {
|
||||
single<IosAuthBridge> {
|
||||
IosAuthBridgeRegistry.instance
|
||||
?: error(
|
||||
"IosAuthBridge not registered before Koin init — call IosAuthBridgeRegistry.shared.setInstance(...) in iOSApp.init.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,159 +2,72 @@
|
||||
|
||||
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 kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.mp.KoinPlatform
|
||||
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,
|
||||
)
|
||||
private val bridge: IosAuthBridge
|
||||
get() = KoinPlatform.getKoin().get()
|
||||
|
||||
actual suspend fun login(): OidcResult {
|
||||
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()
|
||||
bridge.login(presenter) { result ->
|
||||
if (continuation.isActive) continuation.resume(result.toOidcResult())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun refresh(authStateJson: String): OidcResult {
|
||||
val authState =
|
||||
deserializeAuthState(authStateJson)
|
||||
?: return OidcResult.AuthError("Unable to restore iOS AppAuth state")
|
||||
val tokens =
|
||||
decodeTokens(authStateJson)
|
||||
?: return OidcResult.AuthError("Stored iOS auth state is not readable")
|
||||
val refreshToken =
|
||||
tokens.refreshToken
|
||||
?: return OidcResult.AuthError("Stored iOS auth state has no refresh token")
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
bridge.refresh(refreshToken) { result ->
|
||||
if (continuation.isActive) continuation.resume(result.toOidcResult())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
val tokens = decodeTokens(authStateJson) ?: return
|
||||
val idTokenHint = tokens.idToken ?: return
|
||||
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()
|
||||
bridge.endSession(presenter, idTokenHint) {
|
||||
if (continuation.isActive) continuation.resume(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwarded from `iOSApp.swift`'s `onOpenURL` so the Swift bridge can complete
|
||||
* an in-flight authorization. Returns `true` if the URL was consumed.
|
||||
*/
|
||||
@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
|
||||
object IosOidcUrlHandler {
|
||||
fun resume(urlString: String): Boolean {
|
||||
val bridge = KoinPlatform.getKoinOrNull()?.getOrNull<IosAuthBridge>() ?: return false
|
||||
return bridge.resumeExternalUserAgentFlow(urlString)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -165,67 +78,39 @@ private fun topViewController(): UIViewController? {
|
||||
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")
|
||||
}
|
||||
private fun IosAuthBridgeResult.toOidcResult(): OidcResult =
|
||||
when (this) {
|
||||
is IosAuthBridgeResult.Success -> {
|
||||
OidcResult.Success(
|
||||
authStateJson = encodeTokens(tokens),
|
||||
accessToken = tokens.accessToken,
|
||||
idToken = tokens.idToken,
|
||||
expiresAtEpochMillis = tokens.expiresAtEpochMillis,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSError.toOidcResult(): OidcResult =
|
||||
when (code) {
|
||||
OIDErrorCodeUserCanceledAuthorizationFlow,
|
||||
OIDErrorCodeProgramCanceledAuthorizationFlow,
|
||||
-> OidcResult.Cancelled
|
||||
OIDErrorCodeNetworkError -> OidcResult.NetworkError
|
||||
else -> OidcResult.AuthError(localizedDescription)
|
||||
IosAuthBridgeResult.Cancelled -> {
|
||||
OidcResult.Cancelled
|
||||
}
|
||||
|
||||
IosAuthBridgeResult.NetworkError -> {
|
||||
OidcResult.NetworkError
|
||||
}
|
||||
|
||||
is IosAuthBridgeResult.Failed -> {
|
||||
OidcResult.AuthError(message)
|
||||
}
|
||||
}
|
||||
|
||||
@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(),
|
||||
)
|
||||
}
|
||||
private val tokensJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun NSDate?.toEpochMillis(): Long =
|
||||
this?.timeIntervalSince1970?.times(1_000)?.roundToLong() ?: 0L
|
||||
private fun encodeTokens(tokens: IosAuthTokens): String = tokensJson.encodeToString(IosAuthTokens.serializer(), tokens)
|
||||
|
||||
@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)
|
||||
}
|
||||
private fun decodeTokens(value: String): IosAuthTokens? =
|
||||
try {
|
||||
tokensJson.decodeFromString(IosAuthTokens.serializer(), value)
|
||||
} catch (_: SerializationException) {
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package dev.ulfrx.recipe.di
|
||||
|
||||
import dev.ulfrx.recipe.auth.iosAuthModule
|
||||
import dev.ulfrx.recipe.logging.configureLogging
|
||||
|
||||
fun doInitKoin() {
|
||||
configureLogging()
|
||||
initKoin()
|
||||
initKoin {
|
||||
modules(iosAuthModule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ package dev.ulfrx.recipe.auth
|
||||
|
||||
actual class OidcClient {
|
||||
actual suspend fun login(): OidcResult {
|
||||
val token = System.getenv(DEV_AUTH_TOKEN)
|
||||
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
||||
val token =
|
||||
System.getenv(DEV_AUTH_TOKEN)
|
||||
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
||||
|
||||
return OidcResult.Success(
|
||||
authStateJson = "dev:$token",
|
||||
@@ -16,9 +17,10 @@ actual class OidcClient {
|
||||
}
|
||||
|
||||
actual suspend fun refresh(authStateJson: String): OidcResult {
|
||||
val token = authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() }
|
||||
?: System.getenv(DEV_AUTH_TOKEN)
|
||||
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
||||
val token =
|
||||
authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() }
|
||||
?: System.getenv(DEV_AUTH_TOKEN)
|
||||
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
||||
|
||||
return OidcResult.Success(
|
||||
authStateJson = "dev:$token",
|
||||
|
||||
@@ -3,15 +3,9 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
actual class OidcClient {
|
||||
actual suspend fun login(): OidcResult {
|
||||
throw NotImplementedError("Wasm OIDC: v2")
|
||||
}
|
||||
actual suspend fun login(): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
|
||||
|
||||
actual suspend fun refresh(authStateJson: String): OidcResult {
|
||||
throw NotImplementedError("Wasm OIDC: v2")
|
||||
}
|
||||
actual suspend fun refresh(authStateJson: String): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
|
||||
|
||||
actual suspend fun logout(authStateJson: String) {
|
||||
throw NotImplementedError("Wasm OIDC: v2")
|
||||
}
|
||||
actual suspend fun logout(authStateJson: String): Unit = throw NotImplementedError("Wasm OIDC: v2")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user