diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 21d9631..e61713b 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -143,7 +143,7 @@ A mobile-first meal planning app for a small household — pick recipes for the | Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending | | Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending | | Glass/blur effects: Haze (`dev.chrisbanes.haze:haze`) | Purpose-built for glass UI in CMP; handles content capture + efficient re-blur; multiplatform | — Pending | -| Mobile OIDC: AppAuth (Android) + ASWebAuthenticationSession wrapper (iOS), exposed via KMP interface | Platform-native OAuth flows; no cross-platform auth library mature enough yet for this in 2026 | — Pending | +| Mobile OIDC: AppAuth on both Android (Kotlin actual) and iOS (Swift bridge over AppAuth-iOS via SwiftPM, invoked from `iosMain` through Koin), exposed via KMP interface | Platform-native OAuth flows; AppAuth is mature on both platforms. iOS dropped CocoaPods on 2026-04-28 (see `.planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md`) — `embedAndSign` for the shared framework + SwiftPM for AppAuth, mutually exclusive Xcode build modes resolved | — Pending | ### Server tech stack diff --git a/.planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md b/.planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md new file mode 100644 index 0000000..43bfead --- /dev/null +++ b/.planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md @@ -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 { 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. diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md index 518555b..410846c 100644 --- a/.planning/research/PITFALLS.md +++ b/.planning/research/PITFALLS.md @@ -116,7 +116,9 @@ **Warning signs:** Works on Android, fails on iOS (or vice versa); Authentik logs show `invalid_grant`; no `code_challenge` in auth request; fails on release build only. -**How to avoid:** Authentik provider = "Public" + PKCE S256. Register both `recipe://callback` and `recipe://callback/`. AppAuth (Android) + ASWebAuthenticationSession (iOS) with `usePKCE = true`. Keep the redirect URI in one constant in `shared/commonMain`. +**How to avoid:** Authentik provider = "Public" + PKCE S256. Register both `recipe://callback` and `recipe://callback/`. AppAuth on both platforms — Kotlin actual on Android, Swift `AuthBridge` (over AppAuth-iOS via SwiftPM) called from `iosMain` on iOS — with `usePKCE = true`. Keep the redirect URI in one constant in `shared/commonMain`. + +**iOS bridge gotcha:** the Swift `AuthBridge` instance must be set on `IosAuthBridgeRegistry.shared.instance` from `iOSApp.init` *before* `KoinIosKt.doInitKoin()` runs — otherwise Koin's `single` fails on first auth call. Do not try to instantiate AppAuth from pure Kotlin: there is no `cocoapods.AppAuth.*` available since 2026-04-28. **Phase:** Auth. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bfdf93a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,117 @@ +# AGENTS.md + +Guidance for Codex when working in this repository. + +## Project + +**Recipe** (working title) — a household meal planning + pantry + shopping list app built with Kotlin Multiplatform (iOS-primary) and a self-hosted Ktor server. Offline-first with last-write-wins sync; household sharing (me + partner); auth via self-hosted Authentik (OIDC). + +**Core value:** "My week is planned." I pick recipes, the calendar fills up, and I know what we're eating. + +## Planning workflow — always start here + +This project uses GSD (Get Shit Done). All product scope, tech decisions, requirements, and phase structure live in `.planning/`. **Read these files before doing any implementation work.** + +| File | What it is | When to read | +|------|-----------|--------------| +| `.planning/PROJECT.md` | Product scope, locked tech decisions, constraints, out-of-scope | Every session — source of truth | +| `.planning/REQUIREMENTS.md` | 72 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area | +| `.planning/ROADMAP.md` | 11 phases with goals, mapped requirements, success criteria | To know which phase we're in | +| `.planning/STATE.md` | Current phase + high-level pointer | Fast orientation | +| `.planning/config.json` | Workflow settings (YOLO mode, fine granularity, quality models) | Rarely — set once | +| `.planning/research/SUMMARY.md` | Executive synthesis of architecture + pitfalls research | When planning a phase | +| `.planning/research/ARCHITECTURE.md` | Component structure, data flow, build-order reasoning | When structuring code | +| `.planning/research/PITFALLS.md` | 14 critical pitfalls specific to this stack | Before touching auth, sync, or iOS specifics | + +## Tech stack (locked — see PROJECT.md § Key Decisions for full rationale) + +**Client (`composeApp/`):** +- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android, Desktop, Wasm secondary) +- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port of Jetpack Navigation) +- State: ViewModel + StateFlow, method-per-action; `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose` +- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`) +- Local DB: SQLDelight 2.x (raw `.sq` files, generated type-safe Kotlin) +- HTTP: Ktor Client +- Serialization: kotlinx.serialization +- Date/time: kotlinx.datetime +- Logging: Kermit (Touchlab) +- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`) +- Settings/KV: `com.russhwolf:multiplatform-settings` +- Glass/blur: Haze (`dev.chrisbanes.haze:haze`) +- Mobile OIDC: AppAuth on Android; ASWebAuthenticationSession wrapper on iOS (KMP interface) + +**Server (`server/`):** +- Ktor Server 3.x on the user's homelab (alongside Authentik) +- Postgres +- Exposed (DSL only — never the DAO / active-record API) +- Flyway for migrations +- Auth: `io.ktor:ktor-server-auth-jwt` validating Authentik tokens via JWKS + +**Shared (`shared/commonMain`):** +- Domain models + API DTOs only +- No UI, no HTTP, no DB code — keep dependency graph minimal + +**Sync:** Last-write-wins with server-assigned `updated_at`; HTTP polling (20–30s foreground) + pull-to-refresh + debounced push after writes. `POST /api/v1/sync/push`, `GET /api/v1/sync/pull?since=...`. + +## Module structure + +``` +recipe/ +├── composeApp/ # KMP: commonMain + androidMain + iosMain + jvmMain (desktop) +├── iosApp/ # iOS bootstrap (Swift/SwiftUI thin shell) +├── server/ # Ktor + Exposed + Postgres + Flyway +├── shared/ # commonMain: domain + DTOs, no UI/HTTP/DB +├── build-logic/ # Convention plugins (Kotlin/Compose/test config) +├── gradle/libs.versions.toml # Single source of truth for versions +└── .planning/ # GSD planning artifacts (see above) +``` + +**Package layout inside `composeApp/commonMain`:** +``` +dev.ulfrx.recipe/ +├── app/ # App entry, Koin init, theme +├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab) +├── ui/ +│ ├── theme/ # Colors, typography, Haze glass styles +│ ├── components/ # Shared composables +│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel +├── data/{local,remote,repository}/ +└── domain/ # Client-only logic; shared/ handles cross-cutting +``` + +**Rule:** No feature modules in v1. Flat `composeApp/commonMain` with the package layout above. + +## Non-negotiable conventions + +1. **Sync timestamps come from the server, never the device.** `updated_at` is assigned server-side; pulling uses lexicographic `(updated_at, id)` cursor. +2. **Row identity is always UUIDs, never composite natural keys.** `(date, slot)` is not a primary key. See ARCHITECTURE.md § Anti-Patterns. +3. **Household scope is enforced in 3 layers:** client query filter + server `PrincipalResolver` deriving `householdId` from JWT `sub` + DB `household_id` column. Never accept `household_id` from request body. +4. **All sync I/O goes through the `SyncEngine` Koin singleton.** Features write to SQLDelight + outbox, never to HTTP directly. See ARCHITECTURE.md § Pattern 2. +5. **Exposed DSL only, never DAO.** Active-record pattern has footguns with JSONB and coroutines. +6. **`newSuspendedTransaction` for every coroutine-touching handler.** Plain `transaction {}` inside a `suspend` block exhausts the connection pool. +7. **iOS binary flags on day 1:** `kotlin.native.binary.objcDisposeOnMain=false`, `kotlin.native.binary.gc=cms`. +8. **`shared/commonMain` stays light.** No Ktor, Compose, or SQLDelight imports. +9. **Strings externalized from day 1** — Polish-only content, but resources are multi-locale-ready. +10. **Haze blur on chrome only** (tab bar, nav bar), never over fast-scrolling content. + +## Current phase + +See `.planning/STATE.md`. The roadmap has 11 phases; you must work within the currently active one. Don't jump ahead. + +**Build order (load-bearing — do not reorder):** +Phase 1 Infra → Phase 2 Auth → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome (Haze) → Phase 11 Localization + deployment. + +## GSD commands you'll use + +- `/gsd-progress` — show current state and suggest next action +- `/gsd-discuss-phase N` — socratic phase clarification before planning +- `/gsd-plan-phase N` — produce detailed PLAN.md for phase N +- `/gsd-execute-phase N` — execute the plans in phase N +- `/gsd-next` — automatically advance to the next logical step + +## Functional reference + +The legacy PWA mockup at `~/dev/repo/recipe-mockup/` is the **functional** reference (logic, data shapes, user flows). It is **not** a visual reference — UI is being rebuilt around a Liquid-Glass-inspired language. Mine it for planner customization logic (substitutions, amount overrides, product selections), shortfall computation, and shopping aggregation. Do not port its vanilla-JS data or Tailwind styling. + +--- +*Initialized: 2026-04-24. Update when `.planning/PROJECT.md` § Key Decisions gains load-bearing new entries.* diff --git a/CLAUDE.md b/CLAUDE.md index 2633768..05425af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir - Images: Coil 3 (`io.coil-kt.coil3:coil-compose`) - Settings/KV: `com.russhwolf:multiplatform-settings` - Glass/blur: Haze (`dev.chrisbanes.haze:haze`) -- Mobile OIDC: AppAuth on Android; ASWebAuthenticationSession wrapper on iOS (KMP interface) +- Mobile OIDC: AppAuth on both Android (Kotlin actual) and iOS (Swift `AuthBridge` over AppAuth-iOS via SwiftPM, called from `iosMain` through Koin); KMP interface in `commonMain`. iOS dropped CocoaPods on 2026-04-28 — see `.planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md` **Server (`server/`):** - Ktor Server 3.x on the user's homelab (alongside Authentik) diff --git a/build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts b/build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts index ab7e442..f8d7479 100644 --- a/build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts +++ b/build-logic/src/main/kotlin/recipe.kotlin.multiplatform.gradle.kts @@ -24,8 +24,26 @@ kotlin { } } - iosArm64() - iosSimulatorArm64() + // Framework declaration moved here from composeApp/build.gradle.kts when the + // CocoaPods plugin was dropped (2026-04-28). The Xcode run script invokes + // :composeApp:embedAndSignAppleFrameworkForXcode, which needs `baseName` to + // resolve `import ComposeApp` from Swift. `isStatic = true` keeps the link + // shape unchanged from the previous CocoaPods setup. The `:shared` module is + // re-exported so the Swift `AuthBridge` can read `Constants` (single source + // of truth for OIDC issuer / client id / redirect URI). + listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> + target.binaries.framework { + baseName = "ComposeApp" + isStatic = true + // `composeApp` only applies the multiplatform plugin; project deps + // live in its own build file. Skip the export when this convention + // plugin is applied to a module that doesn't depend on `:shared` + // (e.g., shared itself). + project.findProject(":shared")?.let { sharedProject -> + if (project != sharedProject) export(sharedProject) + } + } + } jvm { compilerOptions { diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9f82954..0f14050 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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" } diff --git a/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt b/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt index 815fe60..d9a60bf 100644 --- a/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt @@ -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 } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt index ae19bab..aafacd8 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt @@ -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() + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt index e400175..d9eaafc 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt @@ -29,8 +29,7 @@ class MeClient( if (!accessToken.isNullOrBlank()) { header(HttpHeaders.Authorization, "Bearer ".plus(accessToken)) } - } - .body() + }.body() .toUser() private companion object { diff --git a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthBridge.kt b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthBridge.kt new file mode 100644 index 0000000..f163552 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthBridge.kt @@ -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 +} diff --git a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthModule.kt b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthModule.kt new file mode 100644 index 0000000..491030c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthModule.kt @@ -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` always finds it. + */ +val iosAuthModule = + module { + single { + IosAuthBridgeRegistry.instance + ?: error( + "IosAuthBridge not registered before Koin init — call IosAuthBridgeRegistry.shared.setInstance(...) in iOSApp.init.", + ) + } + } diff --git a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt index c076d3e..789c85d 100644 --- a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt @@ -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 { 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() ?: return false + return bridge.resumeExternalUserAgentFlow(urlString) } } -@OptIn(ExperimentalForeignApi::class) -private suspend fun discoverConfiguration() = - suspendCancellableCoroutine { 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 + } diff --git a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt index becaab7..5a6de74 100644 --- a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt +++ b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt @@ -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) + } } diff --git a/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt index c9b4e2b..c219337 100644 --- a/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt @@ -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", diff --git a/composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt b/composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt index e200a42..c79bd10 100644 --- a/composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt +++ b/composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt @@ -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") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed67642..2655fbe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,8 @@ androidx-lifecycle = "2.10.0" androidx-security-crypto = "1.1.0" androidx-testExt = "1.3.0" appauth = "0.11.1" -appauth-ios = "2.0.0" +# AppAuth-iOS version is pinned in iosApp.xcodeproj's Package.resolved (SwiftPM) +# since 2026-04-28 — see .planning/phases/02-authentication-foundation/DECISION-drop-cocoapods.md. composeHotReload = "1.0.0" composeMultiplatform = "1.10.3" exposed = "0.55.0" @@ -120,6 +121,5 @@ kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ktor = { id = "io.ktor.plugin", version.ref = "ktor" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" } diff --git a/iosApp/Podfile b/iosApp/Podfile deleted file mode 100644 index 0e8ec18..0000000 --- a/iosApp/Podfile +++ /dev/null @@ -1,7 +0,0 @@ -platform :ios, '15.0' - -target 'iosApp' do - use_frameworks! - - pod 'AppAuth' -end diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 2710ea4..f054680 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = D6D5D1F92FA11AF8008BF8AF /* AppAuth */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 4B3C797CB7B3655AAA3375CB /* recipe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = recipe.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -21,6 +25,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 7AAC38CA2AF1E20DE13538FB /* Configuration */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Configuration; + sourceTree = ""; + }; EF7D69B0746377ACEB868F32 /* iosApp */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -29,11 +38,6 @@ path = iosApp; sourceTree = ""; }; - 7AAC38CA2AF1E20DE13538FB /* Configuration */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = Configuration; - sourceTree = ""; - }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -41,6 +45,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -85,6 +90,7 @@ ); name = iosApp; packageProductDependencies = ( + D6D5D1F92FA11AF8008BF8AF /* AppAuth */, ); productName = iosApp; productReference = 4B3C797CB7B3655AAA3375CB /* recipe.app */; @@ -114,6 +120,9 @@ ); mainGroup = 9AD793E4EFD47C3FC2FBCEBD; minimizedProjectReferenceProxies = 1; + packageReferences = ( + D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */, + ); preferredProjectObjectVersion = 77; productRefGroup = DFB8271353F280D44A8EF684 /* Products */; projectDirPath = ""; @@ -167,6 +176,92 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 37796B69615CDCCEFF016651 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = QA9JTAZXDL; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 5CAAA9206DE2876B88C1F201 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 7AAC38CA2AF1E20DE13538FB /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 948490899002FCF04A2150FF /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = 7AAC38CA2AF1E20DE13538FB /* Configuration */; @@ -232,92 +327,6 @@ }; name = Debug; }; - 5CAAA9206DE2876B88C1F201 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 7AAC38CA2AF1E20DE13538FB /* Configuration */; - baseConfigurationReferenceRelativePath = Config.xcconfig; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 37796B69615CDCCEFF016651 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = iosApp/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; AFEF708D67BCEE78AA2502AA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -327,7 +336,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = QA9JTAZXDL; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -368,6 +377,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openid/AppAuth-iOS"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D6D5D1F92FA11AF8008BF8AF /* AppAuth */ = { + isa = XCSwiftPackageProductDependency; + package = D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */; + productName = AppAuth; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 64D626B4C2477EC6512D1B55 /* Project object */; -} \ No newline at end of file +} diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..a40f598 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "c2c3123823fbf9ecb5ff108c887e3a41cb72f13d86620f12b66cac13738096c1", + "pins" : [ + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS", + "state" : { + "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", + "version" : "2.0.0" + } + } + ], + "version" : 3 +} diff --git a/iosApp/iosApp/Auth/AuthBridge.swift b/iosApp/iosApp/Auth/AuthBridge.swift new file mode 100644 index 0000000..f786ab7 --- /dev/null +++ b/iosApp/iosApp/Auth/AuthBridge.swift @@ -0,0 +1,223 @@ +// AuthBridge.swift — Swift implementation of `IosAuthBridge` (declared in +// composeApp/iosMain). Owns all AppAuth-iOS calls so Kotlin code never imports +// AppAuth (DECISION-drop-cocoapods, 2026-04-28). +// +// The instance is registered into `IosAuthBridgeRegistry` from `iOSApp.init` +// before `KoinIosKt.doInitKoin()` so Koin can resolve it. + +import AppAuth +import ComposeApp +import Foundation +import UIKit + +@objc final class AuthBridge: NSObject, IosAuthBridge { + private var serviceConfig: OIDServiceConfiguration? + private var currentSession: OIDExternalUserAgentSession? + + func login( + presentingViewController: UIViewController, + completion: @escaping (IosAuthBridgeResult) -> Void + ) { + discoverConfiguration { [weak self] config, error in + guard let self else { return } + if let error { + completion(self.mapError(error)) + return + } + guard let config else { + completion(IosAuthBridgeResult.Failed(message: "Discovery returned no configuration")) + return + } + + guard let redirectURL = URL(string: Constants.shared.OIDC_REDIRECT_URI) else { + completion(IosAuthBridgeResult.Failed(message: "Redirect URI is not a valid URL")) + return + } + + let request = OIDAuthorizationRequest( + configuration: config, + clientId: Constants.shared.OIDC_CLIENT_ID, + clientSecret: nil, + scopes: ["openid", "profile", "email", "offline_access"], + redirectURL: redirectURL, + responseType: OIDResponseTypeCode, + additionalParameters: nil + ) + + self.currentSession = OIDAuthState.authState( + byPresenting: request, + presenting: presentingViewController + ) { [weak self] authState, error in + guard let self else { return } + self.currentSession = nil + if let error { + completion(self.mapError(error)) + return + } + guard let authState else { + completion(IosAuthBridgeResult.Failed(message: "Authorization completed without an auth state")) + return + } + completion(self.successResult(from: authState)) + } + } + } + + func refresh( + refreshToken: String, + completion: @escaping (IosAuthBridgeResult) -> Void + ) { + discoverConfiguration { [weak self] config, error in + guard let self else { return } + if let error { + completion(self.mapError(error)) + return + } + guard let config else { + completion(IosAuthBridgeResult.Failed(message: "Discovery returned no configuration")) + return + } + guard let redirectURL = URL(string: Constants.shared.OIDC_REDIRECT_URI) else { + completion(IosAuthBridgeResult.Failed(message: "Redirect URI is not a valid URL")) + return + } + + let request = OIDTokenRequest( + configuration: config, + grantType: OIDGrantTypeRefreshToken, + authorizationCode: nil, + redirectURL: redirectURL, + clientID: Constants.shared.OIDC_CLIENT_ID, + clientSecret: nil, + scope: nil, + refreshToken: refreshToken, + codeVerifier: nil, + additionalParameters: nil + ) + + OIDAuthorizationService.perform(request) { [weak self] response, error in + guard let self else { return } + if let error { + completion(self.mapError(error)) + return + } + guard let response, let accessToken = response.accessToken else { + completion(IosAuthBridgeResult.Failed(message: "Refresh returned no access token")) + return + } + let tokens = IosAuthTokens( + accessToken: accessToken, + refreshToken: response.refreshToken ?? refreshToken, + idToken: response.idToken, + expiresAtEpochMillis: epochMillis(response.accessTokenExpirationDate) + ) + completion(IosAuthBridgeResult.Success(tokens: tokens)) + } + } + } + + func endSession( + presentingViewController: UIViewController, + idTokenHint: String, + completion: @escaping () -> Void + ) { + discoverConfiguration { [weak self] config, _ in + guard let self else { completion(); return } + guard let config, config.endSessionEndpoint != nil else { + completion() + return + } + guard let redirectURL = URL(string: Constants.shared.OIDC_REDIRECT_URI) else { + completion() + return + } + guard let agent = OIDExternalUserAgentIOS(presenting: presentingViewController) else { + completion() + return + } + + let request = OIDEndSessionRequest( + configuration: config, + idTokenHint: idTokenHint, + postLogoutRedirectURL: redirectURL, + additionalParameters: nil + ) + self.currentSession = OIDAuthorizationService.present( + request, + externalUserAgent: agent + ) { [weak self] _, _ in + self?.currentSession = nil + completion() + } + } + } + + func resumeExternalUserAgentFlow(url urlString: String) -> Bool { + guard let url = URL(string: urlString) else { return false } + guard let session = currentSession else { return false } + let consumed = session.resumeExternalUserAgentFlow(with: url) + if consumed { currentSession = nil } + return consumed + } + + // MARK: - helpers + + private func discoverConfiguration( + completion: @escaping (OIDServiceConfiguration?, Error?) -> Void + ) { + if let cached = serviceConfig { + completion(cached, nil) + return + } + guard let issuer = URL(string: Constants.shared.OIDC_ISSUER) else { + completion(nil, NSError( + domain: "AuthBridge", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "OIDC_ISSUER is not a valid URL"] + )) + return + } + OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { [weak self] config, error in + self?.serviceConfig = config + completion(config, error) + } + } + + private func mapError(_ error: Error) -> IosAuthBridgeResult { + let nsError = error as NSError + if nsError.domain == OIDGeneralErrorDomain { + switch nsError.code { + case OIDErrorCode.userCanceledAuthorizationFlow.rawValue, + OIDErrorCode.programCanceledAuthorizationFlow.rawValue: + return IosAuthBridgeResult.Cancelled.shared + case OIDErrorCode.networkError.rawValue: + return IosAuthBridgeResult.NetworkError.shared + default: + return IosAuthBridgeResult.Failed(message: nsError.localizedDescription) + } + } + return IosAuthBridgeResult.Failed(message: nsError.localizedDescription) + } + + private func successResult(from authState: OIDAuthState) -> IosAuthBridgeResult { + let tokenResponse = authState.lastTokenResponse + let authorizationResponse = authState.lastAuthorizationResponse + guard let accessToken = tokenResponse?.accessToken ?? authorizationResponse.accessToken else { + return IosAuthBridgeResult.Failed(message: "Auth state has no access token") + } + let refreshToken = tokenResponse?.refreshToken ?? authState.refreshToken + let idToken = tokenResponse?.idToken ?? authorizationResponse.idToken + let tokens = IosAuthTokens( + accessToken: accessToken, + refreshToken: refreshToken, + idToken: idToken, + expiresAtEpochMillis: epochMillis(tokenResponse?.accessTokenExpirationDate) + ) + return IosAuthBridgeResult.Success(tokens: tokens) + } +} + +private func epochMillis(_ date: Date?) -> Int64 { + guard let date else { return 0 } + return Int64((date.timeIntervalSince1970 * 1000.0).rounded()) +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 87efa19..0a33ec6 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -4,6 +4,9 @@ import ComposeApp @main struct iOSApp: App { init() { + // Register the Swift AppAuth bridge before Koin starts so the iOS auth + // module can resolve `IosAuthBridge` (DECISION-drop-cocoapods). + IosAuthBridgeRegistry.shared.instance = AuthBridge() KoinIosKt.doInitKoin() } @@ -14,7 +17,7 @@ struct iOSApp: App { guard url.scheme == "recipe", url.host == "callback" else { return } - _ = IosAppAuthBridge.shared.resumeExternalUserAgentFlow(urlString: url.absoluteString) + _ = IosOidcUrlHandler.shared.resume(urlString: url.absoluteString) } } } diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock index 5f4567d..d3b9b64 100644 --- a/kotlin-js-store/wasm/yarn.lock +++ b/kotlin-js-store/wasm/yarn.lock @@ -6,3 +6,8 @@ version "3.2.0" resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== + +ws@8.18.3: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf index 868205b..a2b422e 100644 --- a/server/src/main/resources/application.conf +++ b/server/src/main/resources/application.conf @@ -19,7 +19,7 @@ database { oidc { # Authentik OIDC issuer (trailing slash required — see Constants.OIDC_ISSUER / D-11). - issuer = "https://auth.example.invalid/application/o/recipe/" + issuer = "https://auth.ulfrx.dev/application/o/recipe-app/" issuer = ${?OIDC_ISSUER} # Audience pinned to client_id per D-07. audience = "recipe-app" diff --git a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt index 8e0af28..46d1dff 100644 --- a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt +++ b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt @@ -34,7 +34,7 @@ public object Constants { * shipping a build; the placeholder keeps tests/CI deterministic without * leaking real infrastructure into the repo. */ - public const val OIDC_ISSUER: String = "https://auth.example.invalid/application/o/recipe/" + public const val OIDC_ISSUER: String = "https://auth.ulfrx.dev/application/o/recipe-app/" /** * OAuth2 client_id registered with Authentik. The same value MUST appear as