Add authentication
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
# 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`:
|
||||
```kotlin
|
||||
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.
|
||||
Reference in New Issue
Block a user