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

@@ -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 | | 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 | | 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 | | 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 ### Server tech stack

View File

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

View File

@@ -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. **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<IosAuthBridge>` 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. **Phase:** Auth.

117
AGENTS.md Normal file
View File

@@ -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 (2030s 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.*

View File

@@ -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`) - Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings` - Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`) - 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/`):** **Server (`server/`):**
- Ktor Server 3.x on the user's homelab (alongside Authentik) - Ktor Server 3.x on the user's homelab (alongside Authentik)

View File

@@ -24,8 +24,26 @@ kotlin {
} }
} }
iosArm64() // Framework declaration moved here from composeApp/build.gradle.kts when the
iosSimulatorArm64() // 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 { jvm {
compilerOptions { compilerOptions {

View File

@@ -7,17 +7,12 @@ plugins {
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload) alias(libs.plugins.composeHotReload)
alias(libs.plugins.kotlinSerialization) 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") id("recipe.quality")
} }
// Top-level project version is required by the Kotlin CocoaPods plugin when no explicit // `group` is referenced by Compose Resources package naming — the
// `version` is set inside the `cocoapods { ... }` block. Mirrors `server/build.gradle.kts` // `compose.resources { packageOfResClass }` block below pins the historical package
// — Gradle artifact metadata only, NOT a library/plugin pin (per `verify-no-version-literals.sh`). // regardless, but keep `group` set explicitly. Gradle artifact metadata only.
group = "dev.ulfrx.recipe" group = "dev.ulfrx.recipe"
version = "1.0.0" version = "1.0.0"
@@ -66,26 +61,6 @@ android {
} }
kotlin { 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 { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(project.dependencies.platform(libs.koin.bom)) implementation(project.dependencies.platform(libs.koin.bom))
@@ -101,7 +76,9 @@ kotlin {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose) 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). // 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 // The MPP variant of `ktor-serialization-kotlinx-json` is required here; the
@@ -135,8 +112,9 @@ kotlin {
implementation(libs.ktor.clientOkhttp) implementation(libs.ktor.clientOkhttp)
} }
iosMain.dependencies { iosMain.dependencies {
// Phase 2 iOS: Darwin engine for Ktor; AppAuth-iOS arrives via the // Phase 2 iOS: Darwin engine for Ktor. AppAuth-iOS is delivered via
// CocoaPods block above so the shared framework links it directly. // 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) implementation(libs.ktor.clientDarwin)
} }
jvmMain.dependencies { jvmMain.dependencies {
@@ -155,11 +133,10 @@ dependencies {
debugImplementation(libs.compose.uiTooling) debugImplementation(libs.compose.uiTooling)
} }
// Adding `group = "dev.ulfrx.recipe"` (required by the Kotlin CocoaPods plugin to render // `group = "dev.ulfrx.recipe"` shifts the Compose Resources `Res` class package from
// the podspec) shifts the Compose Resources `Res` class package from
// `recipe.composeapp.generated.resources` to `dev.ulfrx.recipe.composeapp.generated.resources`, // `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 // breaking the Phase 1 `App.kt` import. Lock the historical package so module-naming
// changes don't cascade into UI code; Plan 02-04+ replaces `App.kt`'s template body anyway. // changes don't cascade into UI code.
compose.resources { compose.resources {
packageOfResClass = "recipe.composeapp.generated.resources" packageOfResClass = "recipe.composeapp.generated.resources"
} }

View File

@@ -10,7 +10,6 @@ import android.content.IntentFilter
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import dev.ulfrx.recipe.shared.Constants import dev.ulfrx.recipe.shared.Constants
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationException
@@ -22,6 +21,7 @@ import net.openid.appauth.EndSessionRequest
import net.openid.appauth.ResponseTypeValues import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse import net.openid.appauth.TokenResponse
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import kotlin.coroutines.resume
actual class OidcClient { actual class OidcClient {
private val context: Context private val context: Context
@@ -41,8 +41,7 @@ actual class OidcClient {
Constants.OIDC_CLIENT_ID, Constants.OIDC_CLIENT_ID,
ResponseTypeValues.CODE, ResponseTypeValues.CODE,
Uri.parse(Constants.OIDC_REDIRECT_URI), Uri.parse(Constants.OIDC_REDIRECT_URI),
) ).setScopes("openid", "profile", "email", "offline_access")
.setScopes("openid", "profile", "email", "offline_access")
.build() .build()
val service = AuthorizationService(context) val service = AuthorizationService(context)
@@ -95,14 +94,21 @@ actual class OidcClient {
AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse(Constants.OIDC_ISSUER)) { configuration, exception -> AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse(Constants.OIDC_ISSUER)) { configuration, exception ->
if (!continuation.isActive) return@fetchFromIssuer if (!continuation.isActive) return@fetchFromIssuer
when { when {
configuration != null -> continuation.resume(ConfigurationOutcome.Success(configuration)) configuration != null -> {
exception != null -> continuation.resume(ConfigurationOutcome.Error(exception)) continuation.resume(ConfigurationOutcome.Success(configuration))
else -> }
exception != null -> {
continuation.resume(ConfigurationOutcome.Error(exception))
}
else -> {
continuation.resume( continuation.resume(
ConfigurationOutcome.Error( ConfigurationOutcome.Error(
AuthorizationException.GeneralErrors.INVALID_DISCOVERY_DOCUMENT, AuthorizationException.GeneralErrors.INVALID_DISCOVERY_DOCUMENT,
), ),
) )
}
} }
} }
} }
@@ -116,10 +122,18 @@ actual class OidcClient {
service.performTokenRequest(authorizationResponse.createTokenExchangeRequest()) { tokenResponse, exception -> service.performTokenRequest(authorizationResponse.createTokenExchangeRequest()) { tokenResponse, exception ->
if (!continuation.isActive) return@performTokenRequest if (!continuation.isActive) return@performTokenRequest
when { when {
exception != null -> continuation.resume(exception.toOidcError()) exception != null -> {
tokenResponse == null -> continuation.resume(OidcResult.AuthError("Token exchange returned no response")) continuation.resume(exception.toOidcError())
tokenResponse.accessToken.isNullOrBlank() -> }
tokenResponse == null -> {
continuation.resume(OidcResult.AuthError("Token exchange returned no response"))
}
tokenResponse.accessToken.isNullOrBlank() -> {
continuation.resume(OidcResult.AuthError("Token exchange returned no access token")) continuation.resume(OidcResult.AuthError("Token exchange returned no access token"))
}
else -> { else -> {
authState.update(tokenResponse, null) authState.update(tokenResponse, null)
continuation.resume(authState.toSuccess(tokenResponse)) continuation.resume(authState.toSuccess(tokenResponse))
@@ -134,9 +148,15 @@ actual class OidcClient {
authState.performActionWithFreshTokens(this) { accessToken, idToken, exception -> authState.performActionWithFreshTokens(this) { accessToken, idToken, exception ->
if (!continuation.isActive) return@performActionWithFreshTokens if (!continuation.isActive) return@performActionWithFreshTokens
when { when {
exception != null -> continuation.resume(exception.toOidcError()) exception != null -> {
accessToken == null -> continuation.resume(OidcResult.AuthError("Refresh returned no access token")) continuation.resume(exception.toOidcError())
else -> }
accessToken == null -> {
continuation.resume(OidcResult.AuthError("Refresh returned no access token"))
}
else -> {
continuation.resume( continuation.resume(
OidcResult.Success( OidcResult.Success(
authStateJson = authState.jsonSerializeString(), authStateJson = authState.jsonSerializeString(),
@@ -145,21 +165,23 @@ actual class OidcClient {
expiresAtEpochMillis = authState.accessTokenExpirationTime ?: 0L, expiresAtEpochMillis = authState.accessTokenExpirationTime ?: 0L,
), ),
) )
}
} }
} }
continuation.invokeOnCancellation { dispose() } continuation.invokeOnCancellation { dispose() }
} }
private suspend fun AuthorizationService.performAuthorization( private suspend fun AuthorizationService.performAuthorization(request: AuthorizationRequest): AuthorizationOutcome =
request: AuthorizationRequest,
): AuthorizationOutcome =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val appContext = context val appContext = context
val action = "${appContext.packageName}.auth.OIDC_AUTH_RESULT.${System.nanoTime()}" val action = "${appContext.packageName}.auth.OIDC_AUTH_RESULT.${System.nanoTime()}"
val filter = IntentFilter(action) val filter = IntentFilter(action)
val receiver = val receiver =
object : BroadcastReceiver() { object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(
context: Context,
intent: Intent,
) {
appContext.unregisterReceiver(this) appContext.unregisterReceiver(this)
if (!continuation.isActive) return if (!continuation.isActive) return
@@ -208,7 +230,10 @@ actual class OidcClient {
val filter = IntentFilter(action) val filter = IntentFilter(action)
val receiver = val receiver =
object : BroadcastReceiver() { object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(
context: Context,
intent: Intent,
) {
appContext.unregisterReceiver(this) appContext.unregisterReceiver(this)
if (continuation.isActive) continuation.resume(Unit) if (continuation.isActive) continuation.resume(Unit)
} }
@@ -256,13 +281,17 @@ actual class OidcClient {
private fun AuthorizationException.isCancellation(): Boolean = private fun AuthorizationException.isCancellation(): Boolean =
type == AuthorizationException.TYPE_GENERAL_ERROR && 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 = private fun AuthorizationException.isNetworkFailure(): Boolean =
type == AuthorizationException.TYPE_GENERAL_ERROR && 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( private fun Context.registerPrivateReceiver(
receiver: BroadcastReceiver, receiver: BroadcastReceiver,
@@ -276,20 +305,27 @@ actual class OidcClient {
} }
} }
private fun pendingIntentFlags(): Int = private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
private sealed interface AuthorizationOutcome { 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 data object Cancelled : AuthorizationOutcome
} }
private sealed interface ConfigurationOutcome { 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 { object : OidcClientGateway {
override suspend fun login(): OidcResult = oidcClient.login() override suspend fun login(): OidcResult = oidcClient.login()
override suspend fun refresh(authStateJson: String): OidcResult = override suspend fun refresh(authStateJson: String): OidcResult = oidcClient.refresh(authStateJson)
oidcClient.refresh(authStateJson)
override suspend fun logout(authStateJson: String) { override suspend fun logout(authStateJson: String) {
oidcClient.logout(authStateJson) oidcClient.logout(authStateJson)
@@ -85,6 +84,7 @@ class AuthSession(
when (val refreshResult = oidcClient.refresh(storedJson)) { when (val refreshResult = oidcClient.refresh(storedJson)) {
is OidcResult.Success -> authenticate(refreshResult) is OidcResult.Success -> authenticate(refreshResult)
OidcResult.Cancelled, OidcResult.Cancelled,
OidcResult.NetworkError, OidcResult.NetworkError,
is OidcResult.AuthError, is OidcResult.AuthError,
@@ -98,14 +98,17 @@ class AuthSession(
authenticate(loginResult) authenticate(loginResult)
AuthLoginResult.Success AuthLoginResult.Success
} }
OidcResult.Cancelled -> { OidcResult.Cancelled -> {
_state.value = AuthState.Unauthenticated _state.value = AuthState.Unauthenticated
AuthLoginResult.Cancelled AuthLoginResult.Cancelled
} }
OidcResult.NetworkError -> { OidcResult.NetworkError -> {
_state.value = AuthState.Unauthenticated _state.value = AuthState.Unauthenticated
AuthLoginResult.NetworkError AuthLoginResult.NetworkError
} }
is OidcResult.AuthError -> { is OidcResult.AuthError -> {
_state.value = AuthState.Unauthenticated _state.value = AuthState.Unauthenticated
AuthLoginResult.Failed(loginResult.message) AuthLoginResult.Failed(loginResult.message)
@@ -123,26 +126,29 @@ class AuthSession(
clearSession() clearSession()
} }
suspend fun getAccessToken(): String? = suspend fun getAccessToken(): String? = refreshBearerTokens()?.accessToken
refreshBearerTokens()?.accessToken
fun currentBearerTokens(): BearerTokens? = currentTokens fun currentBearerTokens(): BearerTokens? = currentTokens
suspend fun refreshBearerTokens(): BearerTokens? { suspend fun refreshBearerTokens(): BearerTokens? {
val storedJson = store.read() ?: return null.also { val storedJson =
clearSession() store.read() ?: return null.also {
} clearSession()
}
return when (val refreshResult = oidcClient.refresh(storedJson)) { return when (val refreshResult = oidcClient.refresh(storedJson)) {
is OidcResult.Success -> { is OidcResult.Success -> {
persistTokens(refreshResult) persistTokens(refreshResult)
currentTokens currentTokens
} }
OidcResult.Cancelled, OidcResult.Cancelled,
OidcResult.NetworkError, OidcResult.NetworkError,
is OidcResult.AuthError, is OidcResult.AuthError,
-> null.also { -> {
clearSession() null.also {
clearSession()
}
} }
} }
} }

View File

@@ -29,8 +29,7 @@ class MeClient(
if (!accessToken.isNullOrBlank()) { if (!accessToken.isNullOrBlank()) {
header(HttpHeaders.Authorization, "Bearer ".plus(accessToken)) header(HttpHeaders.Authorization, "Bearer ".plus(accessToken))
} }
} }.body<dev.ulfrx.recipe.shared.dto.MeResponse>()
.body<dev.ulfrx.recipe.shared.dto.MeResponse>()
.toUser() .toUser()
private companion object { 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 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.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import platform.Foundation.NSData import kotlinx.serialization.SerializationException
import platform.Foundation.NSDate import kotlinx.serialization.json.Json
import platform.Foundation.NSError import org.koin.mp.KoinPlatform
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 platform.UIKit.UIApplication import platform.UIKit.UIApplication
import platform.UIKit.UIViewController import platform.UIKit.UIViewController
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.math.roundToLong
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
actual class OidcClient { actual class OidcClient {
actual suspend fun login(): OidcResult { private val bridge: IosAuthBridge
val configuration = discoverConfiguration() ?: return OidcResult.NetworkError get() = KoinPlatform.getKoin().get()
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,
)
actual suspend fun login(): OidcResult {
val presenter = val presenter =
topViewController() topViewController()
?: return OidcResult.AuthError("Unable to find an iOS view controller for OIDC login") ?: return OidcResult.AuthError("Unable to find an iOS view controller for OIDC login")
return suspendCancellableCoroutine { continuation -> return suspendCancellableCoroutine { continuation ->
val session = bridge.login(presenter) { result ->
OIDAuthState.authStateByPresentingAuthorizationRequest( if (continuation.isActive) continuation.resume(result.toOidcResult())
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()
} }
} }
} }
actual suspend fun refresh(authStateJson: String): OidcResult { actual suspend fun refresh(authStateJson: String): OidcResult {
val authState = val tokens =
deserializeAuthState(authStateJson) decodeTokens(authStateJson)
?: return OidcResult.AuthError("Unable to restore iOS AppAuth state") ?: 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 -> return suspendCancellableCoroutine { continuation ->
authState.performActionWithFreshTokens { accessToken, idToken, error -> bridge.refresh(refreshToken) { result ->
if (!continuation.isActive) return@performActionWithFreshTokens if (continuation.isActive) continuation.resume(result.toOidcResult())
if (error != null) {
continuation.resume(error.toOidcResult())
} else {
continuation.resume(
authState.toSuccess(
accessToken = accessToken,
idToken = idToken,
),
)
}
} }
} }
} }
actual suspend fun logout(authStateJson: String) { actual suspend fun logout(authStateJson: String) {
val authState = deserializeAuthState(authStateJson) ?: return val tokens = decodeTokens(authStateJson) ?: return
val configuration = authState.lastAuthorizationResponse.request.configuration val idTokenHint = tokens.idToken ?: return
if (configuration.endSessionEndpoint == null) return val presenter = topViewController() ?: 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
suspendCancellableCoroutine<Unit> { continuation -> suspendCancellableCoroutine<Unit> { continuation ->
val session = bridge.endSession(presenter, idTokenHint) {
OIDAuthorizationService.presentEndSessionRequest( if (continuation.isActive) continuation.resume(Unit)
request = request,
externalUserAgent = OIDExternalUserAgentIOS(presentingViewController = presenter),
callback = { _, _ ->
IosAppAuthBridge.clearCurrentAuthorizationFlow()
if (continuation.isActive) continuation.resume(Unit)
},
)
IosAppAuthBridge.currentAuthorizationFlow = session
continuation.invokeOnCancellation {
session.cancel()
IosAppAuthBridge.clearCurrentAuthorizationFlow()
} }
} }
} }
} }
/**
* 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) @OptIn(ExperimentalForeignApi::class)
object IosAppAuthBridge { object IosOidcUrlHandler {
internal var currentAuthorizationFlow: OIDExternalUserAgentSessionProtocol? = null fun resume(urlString: String): Boolean {
val bridge = KoinPlatform.getKoinOrNull()?.getOrNull<IosAuthBridge>() ?: return false
fun resumeExternalUserAgentFlow(urlString: String): Boolean { return bridge.resumeExternalUserAgentFlow(urlString)
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
} }
} }
@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) @OptIn(ExperimentalForeignApi::class)
private fun topViewController(): UIViewController? { private fun topViewController(): UIViewController? {
val root = UIApplication.sharedApplication.keyWindow?.rootViewController val root = UIApplication.sharedApplication.keyWindow?.rootViewController
@@ -165,67 +78,39 @@ private fun topViewController(): UIViewController? {
return current return current
} }
@OptIn(ExperimentalForeignApi::class) private fun IosAuthBridgeResult.toOidcResult(): OidcResult =
private fun OIDAuthState?.toOidcResult(error: NSError?): OidcResult { when (this) {
if (error != null) return error.toOidcResult() is IosAuthBridgeResult.Success -> {
return this?.toSuccess() ?: OidcResult.AuthError("OIDC login completed without an auth state") OidcResult.Success(
} authStateJson = encodeTokens(tokens),
accessToken = tokens.accessToken,
idToken = tokens.idToken,
expiresAtEpochMillis = tokens.expiresAtEpochMillis,
)
}
@OptIn(ExperimentalForeignApi::class) IosAuthBridgeResult.Cancelled -> {
private fun NSError.toOidcResult(): OidcResult = OidcResult.Cancelled
when (code) { }
OIDErrorCodeUserCanceledAuthorizationFlow,
OIDErrorCodeProgramCanceledAuthorizationFlow, IosAuthBridgeResult.NetworkError -> {
-> OidcResult.Cancelled OidcResult.NetworkError
OIDErrorCodeNetworkError -> OidcResult.NetworkError }
else -> OidcResult.AuthError(localizedDescription)
is IosAuthBridgeResult.Failed -> {
OidcResult.AuthError(message)
}
} }
@OptIn(ExperimentalForeignApi::class) private val tokensJson = Json { ignoreUnknownKeys = true }
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(),
)
}
@OptIn(ExperimentalForeignApi::class) private fun encodeTokens(tokens: IosAuthTokens): String = tokensJson.encodeToString(IosAuthTokens.serializer(), tokens)
private fun NSDate?.toEpochMillis(): Long =
this?.timeIntervalSince1970?.times(1_000)?.roundToLong() ?: 0L
@OptIn(ExperimentalForeignApi::class) private fun decodeTokens(value: String): IosAuthTokens? =
private fun OIDAuthState.serializeAuthState(): String { try {
val data = tokensJson.decodeFromString(IosAuthTokens.serializer(), value)
NSKeyedArchiver.archivedDataWithRootObject( } catch (_: SerializationException) {
`object` = this, null
requiringSecureCoding = true, } catch (_: IllegalArgumentException) {
error = null, 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)
}

View File

@@ -1,8 +1,11 @@
package dev.ulfrx.recipe.di package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.auth.iosAuthModule
import dev.ulfrx.recipe.logging.configureLogging import dev.ulfrx.recipe.logging.configureLogging
fun doInitKoin() { fun doInitKoin() {
configureLogging() configureLogging()
initKoin() initKoin {
modules(iosAuthModule)
}
} }

View File

@@ -4,8 +4,9 @@ package dev.ulfrx.recipe.auth
actual class OidcClient { actual class OidcClient {
actual suspend fun login(): OidcResult { actual suspend fun login(): OidcResult {
val token = System.getenv(DEV_AUTH_TOKEN) val token =
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set") System.getenv(DEV_AUTH_TOKEN)
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
return OidcResult.Success( return OidcResult.Success(
authStateJson = "dev:$token", authStateJson = "dev:$token",
@@ -16,9 +17,10 @@ actual class OidcClient {
} }
actual suspend fun refresh(authStateJson: String): OidcResult { actual suspend fun refresh(authStateJson: String): OidcResult {
val token = authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() } val token =
?: System.getenv(DEV_AUTH_TOKEN) authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() }
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set") ?: System.getenv(DEV_AUTH_TOKEN)
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
return OidcResult.Success( return OidcResult.Success(
authStateJson = "dev:$token", authStateJson = "dev:$token",

View File

@@ -3,15 +3,9 @@
package dev.ulfrx.recipe.auth package dev.ulfrx.recipe.auth
actual class OidcClient { actual class OidcClient {
actual suspend fun login(): OidcResult { actual suspend fun login(): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
throw NotImplementedError("Wasm OIDC: v2")
}
actual suspend fun refresh(authStateJson: String): OidcResult { actual suspend fun refresh(authStateJson: String): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
throw NotImplementedError("Wasm OIDC: v2")
}
actual suspend fun logout(authStateJson: String) { actual suspend fun logout(authStateJson: String): Unit = throw NotImplementedError("Wasm OIDC: v2")
throw NotImplementedError("Wasm OIDC: v2")
}
} }

View File

@@ -11,7 +11,8 @@ androidx-lifecycle = "2.10.0"
androidx-security-crypto = "1.1.0" androidx-security-crypto = "1.1.0"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
appauth = "0.11.1" 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" composeHotReload = "1.0.0"
composeMultiplatform = "1.10.3" composeMultiplatform = "1.10.3"
exposed = "0.55.0" 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" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" } ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 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" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" } flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }

View File

@@ -1,7 +0,0 @@
platform :ios, '15.0'
target 'iosApp' do
use_frameworks!
pod 'AppAuth'
end

View File

@@ -6,6 +6,10 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = D6D5D1F92FA11AF8008BF8AF /* AppAuth */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
4B3C797CB7B3655AAA3375CB /* recipe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = recipe.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4B3C797CB7B3655AAA3375CB /* recipe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = recipe.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -21,6 +25,11 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
7AAC38CA2AF1E20DE13538FB /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Configuration;
sourceTree = "<group>";
};
EF7D69B0746377ACEB868F32 /* iosApp */ = { EF7D69B0746377ACEB868F32 /* iosApp */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
@@ -29,11 +38,6 @@
path = iosApp; path = iosApp;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
7AAC38CA2AF1E20DE13538FB /* Configuration */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Configuration;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -41,6 +45,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D6D5D1FA2FA11AF8008BF8AF /* AppAuth in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -85,6 +90,7 @@
); );
name = iosApp; name = iosApp;
packageProductDependencies = ( packageProductDependencies = (
D6D5D1F92FA11AF8008BF8AF /* AppAuth */,
); );
productName = iosApp; productName = iosApp;
productReference = 4B3C797CB7B3655AAA3375CB /* recipe.app */; productReference = 4B3C797CB7B3655AAA3375CB /* recipe.app */;
@@ -114,6 +120,9 @@
); );
mainGroup = 9AD793E4EFD47C3FC2FBCEBD; mainGroup = 9AD793E4EFD47C3FC2FBCEBD;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
D6D5D1F82FA11AF8008BF8AF /* XCRemoteSwiftPackageReference "AppAuth-iOS" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = DFB8271353F280D44A8EF684 /* Products */; productRefGroup = DFB8271353F280D44A8EF684 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -167,6 +176,92 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration 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 */ = { 948490899002FCF04A2150FF /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = 7AAC38CA2AF1E20DE13538FB /* Configuration */; baseConfigurationReferenceAnchor = 7AAC38CA2AF1E20DE13538FB /* Configuration */;
@@ -232,92 +327,6 @@
}; };
name = Debug; 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 */ = { AFEF708D67BCEE78AA2502AA /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -327,7 +336,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
DEVELOPMENT_TEAM = "${TEAM_ID}"; DEVELOPMENT_TEAM = QA9JTAZXDL;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = iosApp/Info.plist; INFOPLIST_FILE = iosApp/Info.plist;
@@ -368,6 +377,25 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; rootObject = 64D626B4C2477EC6512D1B55 /* Project object */;
} }

View File

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

View File

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

View File

@@ -4,6 +4,9 @@ import ComposeApp
@main @main
struct iOSApp: App { struct iOSApp: App {
init() { 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() KoinIosKt.doInitKoin()
} }
@@ -14,7 +17,7 @@ struct iOSApp: App {
guard url.scheme == "recipe", url.host == "callback" else { guard url.scheme == "recipe", url.host == "callback" else {
return return
} }
_ = IosAppAuthBridge.shared.resumeExternalUserAgentFlow(urlString: url.absoluteString) _ = IosOidcUrlHandler.shared.resume(urlString: url.absoluteString)
} }
} }
} }

View File

@@ -6,3 +6,8 @@
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== 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==

View File

@@ -19,7 +19,7 @@ database {
oidc { oidc {
# Authentik OIDC issuer (trailing slash required — see Constants.OIDC_ISSUER / D-11). # 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} issuer = ${?OIDC_ISSUER}
# Audience pinned to client_id per D-07. # Audience pinned to client_id per D-07.
audience = "recipe-app" audience = "recipe-app"

View File

@@ -34,7 +34,7 @@ public object Constants {
* shipping a build; the placeholder keeps tests/CI deterministic without * shipping a build; the placeholder keeps tests/CI deterministic without
* leaking real infrastructure into the repo. * 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 * OAuth2 client_id registered with Authentik. The same value MUST appear as