Files
recipe/.planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md
2026-04-29 20:54:13 +02:00

8.2 KiB

Decision: Drop CocoaPods, switch to embedAndSign + SwiftPM bridge

Date: 2026-04-28 Status: Decided, not yet executed Trigger: Xcode build fails with "Incompatible 'embedAndSign' Task with CocoaPods Dependencies." The Xcode run script calls :composeApp:embedAndSignAppleFrameworkForXcode while the Kotlin CocoaPods plugin is also active — these two iOS framework integration modes are mutually exclusive.

Decision

Remove the Kotlin CocoaPods plugin. Deliver the shared framework via embedAndSign (current Xcode run script stays). Deliver AppAuth-iOS via Swift Package Manager in iosApp.xcodeproj. Move all AppAuth calls out of iosMain Kotlin and behind a Swift bridge injected via Koin.

Why

  • One integration mode, fewer moving parts (no Podfile, Pods/, .xcworkspace, no Ruby/CocoaPods gem prerequisite).
  • Aligns with where Apple tooling is going (SwiftPM is the strategic direction; CocoaPods is in maintenance).
  • AppAuth surface in iosMain is small and contained — migration is local.
  • Eliminates the entire class of "cocoapods vs embedAndSign" Xcode build errors.

Cost (what we accept)

  • OidcClient.ios.kt (~231 lines) is rewritten to call a Swift bridge instead of cocoapods.AppAuth.* cinterop bindings.
  • iosApp/ gains a small Swift class implementing the bridge using AppAuth-iOS APIs directly.
  • D-01 (PROJECT.md) remains AppAuth-iOS — only the delivery channel changes (CocoaPods → SwiftPM).

Surface area in this repo (scanned)

AppAuth-iOS is used in exactly one place:

  • composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt (231 lines, imports cocoapods.AppAuth.*OIDAuthState, OIDAuthorizationRequest, OIDAuthorizationService, OIDEndSessionRequest, OIDExternalUserAgentIOS, OIDResponseTypeCode, error codes).

SecureAuthStateStore.ios.kt does NOT depend on AppAuth — it serializes OIDAuthState via NSKeyedArchiver. After migration, the serialized blob crosses the bridge as NSData/ByteArray and the Swift side does the archiving. Or we change the on-disk format to JSON of our own AuthState (cleaner; recommended).

Work plan (execute in a fresh session)

1 — Gradle / build config

  • composeApp/build.gradle.kts:
    • Remove id("org.jetbrains.kotlin.native.cocoapods") from the plugins { } block.
    • Remove the entire cocoapods { ... } block inside kotlin { }.
    • Keep group = "dev.ulfrx.recipe" and version = "1.0.0" (the comment explaining "required by cocoapods plugin" can be deleted; group is also referenced by Compose Resources package naming — do NOT change group).
    • The framework declaration is already provided by the recipe.kotlin.multiplatform convention plugin via iosTarget.binaries.framework. Verify it sets baseName = "ComposeApp" and isStatic = true. If not, add the binaries.framework { baseName = "ComposeApp"; isStatic = true } block to the iOS targets in the convention plugin (or inline in composeApp).
  • gradle/libs.versions.toml: leave appauth-ios version entry — repurpose it as the documented SwiftPM pin in docs/authentik-setup.md. Or delete it and put the version only in the iOS project's Package.resolved.

2 — Delete CocoaPods artifacts

  • Delete: iosApp/Podfile, iosApp/Podfile.lock, iosApp/Pods/, iosApp/iosApp.xcworkspace/.
  • From now on open iosApp/iosApp.xcodeproj directly (not .xcworkspace).
  • The Xcode run script stays — it already invokes ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode.

3 — Add AppAuth-iOS via SwiftPM in Xcode

  • Open iosApp.xcodeproj → File → Add Package Dependencies → https://github.com/openid/AppAuth-iOS → choose "Up to Next Major" from the same major version currently in libs.versions.toml.
  • Add AppAuth product to the iosApp target.

4 — Swift bridge (in iosApp/iosApp/)

  • Define a Kotlin interface in composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthBridge.kt:
    interface IosAuthBridge {
        suspend fun authorize(presentingVc: UIViewController): AuthBridgeResult
        suspend fun endSession(presentingVc: UIViewController, idToken: String): AuthBridgeResult
        // refresh, plus serialize/deserialize hooks if you keep OIDAuthState as the persisted blob
    }
    sealed class AuthBridgeResult { data class Success(...) : AuthBridgeResult(); data class Error(val kind: ErrorKind, val message: String?) : AuthBridgeResult(); object Cancelled : AuthBridgeResult() }
    
    Mark with @OptIn(ExperimentalObjCName::class) and @ObjCName so Swift sees stable names.
  • Implement in Swift: iosApp/iosApp/Auth/AuthBridge.swift — uses OIDAuthState, OIDAuthorizationService, etc. Maps AppAuth callbacks → suspending Kotlin via kotlinx.coroutines continuation helpers (or callback-style if simpler — pick one and stay consistent).
  • Decide AuthState persistence format:
    • Option A (recommended): Define a Kotlin AuthTokens data class (access token, refresh token, id token, expiresAt, scopes). Bridge returns this. SecureAuthStateStore.ios.kt persists it as JSON via kotlinx.serialization. Removes the last AppAuth dependency from Kotlin and lets you delete NSKeyedArchiver/NSKeyedUnarchiver plumbing.
    • Option B: Keep persisting opaque NSData blob produced by Swift via NSKeyedArchiver(rootObject: OIDAuthState). Less rewrite of SecureAuthStateStore, but Kotlin is now blind to token contents (can't compute expiry locally).
  • Wire in Koin from iosApp entry point (MainViewController.kt or wherever Koin's iOS module starts): single<IosAuthBridge> { IosAuthBridgeImpl() } where IosAuthBridgeImpl is an @ObjCName-annotated Kotlin shim that holds a reference to a Swift-side instance handed over from iosApp Swift code at startup.

5 — Rewrite OidcClient.ios.kt

  • Drop all cocoapods.AppAuth.* imports.
  • Inject IosAuthBridge via constructor (Koin).
  • Each OidcClient method becomes a thin call into the bridge + result mapping to the existing common OidcClient contract (Cancelled / NetworkError / Failed / Success).
  • Error code mapping (OIDErrorCodeUserCanceledAuthorizationFlow, OIDErrorCodeProgramCanceledAuthorizationFlow, OIDErrorCodeNetworkError) now lives in Swift, surfaced as AuthBridgeResult.ErrorKind enum.

6 — Verification

  • ./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 — Kotlin compiles without cocoapods imports.
  • Open Xcode, build & run on simulator — no "incompatible task" error.
  • Re-run the Phase 02-07 manual UAT (login → welcome → logout → token refresh).
  • ./gradlew check — all existing tests still green; LoginViewModelTest / AuthSessionTest are unaffected (they test common code, not iOS actuals).

7 — Docs / planning updates

  • .planning/PROJECT.md § Key Decisions: amend D-01 — "AppAuth-iOS via SwiftPM, called through a Swift bridge from iosMain. CocoaPods plugin removed 2026-04-28."
  • .planning/research/PITFALLS.md: replace the cocoapods-specific pitfall (if any) with a SwiftPM-bridge pitfall ("Swift bridge instances must be handed in from iosApp at startup; do not try to instantiate AppAuth from pure Kotlin").
  • docs/authentik-setup.md (or create it): document SwiftPM step for new contributors, AppAuth-iOS version pin, and how to open the project (.xcodeproj directly, not .xcworkspace).
  • CLAUDE.md "Tech stack" line: change "Mobile OIDC: AppAuth on Android; ASWebAuthenticationSession wrapper on iOS (KMP interface)" — your current code uses AppAuth-iOS, not ASWebAuthenticationSession; keep AppAuth-iOS but note the SwiftPM + Swift-bridge delivery.

Out of scope for this change

  • Phase 02-07 manual UAT — must be re-run after the migration, but on the same auth flow.
  • Pre-existing failures already logged in .planning/phases/02-authentication-foundation/deferred-items.md (Android Robolectric test, iOS ktlint warning).

Rollback

If the SwiftPM bridge proves harder than expected:

  • git revert the migration commit(s).
  • Restore Podfile, run pod install, reopen .xcworkspace.
  • The original cocoapods setup is recoverable from git history.

Resume signal

Start a fresh Claude Code session in /Users/rwilk/dev/repo/recipe. Open this file as the briefing. Plan 02-07 stays at the human-verify checkpoint until the migration lands and UAT passes.