Drop cocoapods
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
117
AGENTS.md
Normal 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 (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.*
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +94,15 @@ 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,
|
||||||
@@ -106,6 +111,7 @@ actual class OidcClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun exchangeCode(
|
private suspend fun exchangeCode(
|
||||||
service: AuthorizationService,
|
service: AuthorizationService,
|
||||||
@@ -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(),
|
||||||
@@ -147,19 +167,21 @@ actual class OidcClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +126,13 @@ 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 =
|
||||||
|
store.read() ?: return null.also {
|
||||||
clearSession()
|
clearSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,14 +141,17 @@ class AuthSession(
|
|||||||
persistTokens(refreshResult)
|
persistTokens(refreshResult)
|
||||||
currentTokens
|
currentTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
OidcResult.Cancelled,
|
OidcResult.Cancelled,
|
||||||
OidcResult.NetworkError,
|
OidcResult.NetworkError,
|
||||||
is OidcResult.AuthError,
|
is OidcResult.AuthError,
|
||||||
-> null.also {
|
-> {
|
||||||
|
null.also {
|
||||||
clearSession()
|
clearSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun authenticate(result: OidcResult.Success) {
|
private suspend fun authenticate(result: OidcResult.Success) {
|
||||||
persistTokens(result)
|
persistTokens(result)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,157 +2,70 @@
|
|||||||
|
|
||||||
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(
|
|
||||||
request = request,
|
|
||||||
externalUserAgent = OIDExternalUserAgentIOS(presentingViewController = presenter),
|
|
||||||
callback = { _, _ ->
|
|
||||||
IosAppAuthBridge.clearCurrentAuthorizationFlow()
|
|
||||||
if (continuation.isActive) continuation.resume(Unit)
|
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)
|
||||||
@@ -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,
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
idToken = tokens.idToken,
|
||||||
private fun NSError.toOidcResult(): OidcResult =
|
expiresAtEpochMillis = tokens.expiresAtEpochMillis,
|
||||||
when (code) {
|
|
||||||
OIDErrorCodeUserCanceledAuthorizationFlow,
|
|
||||||
OIDErrorCodeProgramCanceledAuthorizationFlow,
|
|
||||||
-> OidcResult.Cancelled
|
|
||||||
OIDErrorCodeNetworkError -> OidcResult.NetworkError
|
|
||||||
else -> OidcResult.AuthError(localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
@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(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
IosAuthBridgeResult.Cancelled -> {
|
||||||
private fun NSDate?.toEpochMillis(): Long =
|
OidcResult.Cancelled
|
||||||
this?.timeIntervalSince1970?.times(1_000)?.roundToLong() ?: 0L
|
|
||||||
|
|
||||||
@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)
|
IosAuthBridgeResult.NetworkError -> {
|
||||||
private fun deserializeAuthState(value: String): OIDAuthState? {
|
OidcResult.NetworkError
|
||||||
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)
|
is IosAuthBridgeResult.Failed -> {
|
||||||
private fun extractArchive(value: String): String? {
|
OidcResult.AuthError(message)
|
||||||
return Regex(""""archive":"([^"]+)"""")
|
}
|
||||||
.find(value)
|
}
|
||||||
?.groupValues
|
|
||||||
?.getOrNull(1)
|
private val tokensJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun encodeTokens(tokens: IosAuthTokens): String = tokensJson.encodeToString(IosAuthTokens.serializer(), tokens)
|
||||||
|
|
||||||
|
private fun decodeTokens(value: String): IosAuthTokens? =
|
||||||
|
try {
|
||||||
|
tokensJson.decodeFromString(IosAuthTokens.serializer(), value)
|
||||||
|
} catch (_: SerializationException) {
|
||||||
|
null
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ 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 =
|
||||||
|
System.getenv(DEV_AUTH_TOKEN)
|
||||||
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
||||||
|
|
||||||
return OidcResult.Success(
|
return OidcResult.Success(
|
||||||
@@ -16,7 +17,8 @@ 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 =
|
||||||
|
authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() }
|
||||||
?: System.getenv(DEV_AUTH_TOKEN)
|
?: System.getenv(DEV_AUTH_TOKEN)
|
||||||
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
platform :ios, '15.0'
|
|
||||||
|
|
||||||
target 'iosApp' do
|
|
||||||
use_frameworks!
|
|
||||||
|
|
||||||
pod 'AppAuth'
|
|
||||||
end
|
|
||||||
@@ -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 */;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
223
iosApp/iosApp/Auth/AuthBridge.swift
Normal file
223
iosApp/iosApp/Auth/AuthBridge.swift
Normal 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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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==
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user