Drop cocoapods

This commit is contained in:
2026-04-28 21:41:52 +02:00
parent 0a15c9d9b5
commit 673bbaaba3
25 changed files with 890 additions and 373 deletions

View File

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

View File

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

View File

@@ -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()
}
}
}
}

View File

@@ -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 {

View File

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

View File

@@ -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.",
)
}
}

View File

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

View File

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

View File

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

View File

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