--- phase: 02-authentication-foundation plan: 05 subsystem: auth tags: [oidc, appauth, ios, keychain, swiftui, callback] requires: - phase: 02-authentication-foundation provides: 02-01 CocoaPods/AppAuth dependency wiring and OIDC constants - phase: 02-authentication-foundation provides: 02-03 common OidcClient and SecureAuthStateStore contracts provides: - iOS AppAuth OidcClient actual with login, refresh, logout, and callback bridge - iOS Keychain-backed SecureAuthStateStore using kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - recipe URL scheme registration in Info.plist - SwiftUI onOpenURL callback forwarding into the current AppAuth flow - iosApp Podfile with AppAuth pod integration affects: [02-06-auth-session-ui, 02-07-auth-integration-verification] tech-stack: added: - AppAuth CocoaPod reference in iosApp/Podfile patterns: - "iOS AppAuth bridge: Kotlin singleton holds currentAuthorizationFlow; SwiftUI forwards recipe://callback URLs by absolute string." - "iOS AuthState persistence: full OIDAuthState NSSecureCoding archive wrapped in an opaque JSON string and stored through KeychainSettings." key-files: created: - composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt - composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt - iosApp/Podfile modified: - iosApp/iosApp/Info.plist - iosApp/iosApp/iOSApp.swift key-decisions: - "AppAuth-iOS AuthState persistence uses NSSecureCoding wrapped in JSON because AppAuth-iOS 2.0.0 does not expose the Android-style serialize()/jsonDeserialize API." - "SecureAuthStateStore was implemented in the first task commit because the Task 1 compile gate cannot pass while the common expect class lacks an iOS actual." - "SwiftUI forwards only recipe://callback URLs to the KMP bridge; other URLs are ignored before AppAuth sees them." patterns-established: - "Never log token-bearing values in iOS auth actuals; token variables are only returned through OidcResult or stored in Keychain." - "Mobile callback state remains inside AppAuth's current external user-agent session and is consumed once." requirements-completed: [AUTH-01, AUTH-02, AUTH-04, AUTH-05] duration: 27m completed: 2026-04-28 --- # Phase 02 Plan 05: iOS AppAuth Actuals Summary **iOS AppAuth login, fresh-token refresh, RP-initiated logout, Keychain AuthState persistence, and recipe://callback forwarding behind the Phase 02 common auth contracts.** ## Performance - **Duration:** 27 min - **Started:** 2026-04-28T13:52:54Z - **Completed:** 2026-04-28T14:19:03Z - **Tasks:** 2 - **Files modified:** 5 ## Accomplishments - Added the iOS `OidcClient` actual using AppAuth discovery, authorization-code flow with PKCE, exact `openid profile email offline_access` scopes, fresh-token refresh, and end-session logout. - Added the iOS secure store actual using Keychain-backed settings with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. - Registered the `recipe` URL scheme and wired SwiftUI `.onOpenURL` to forward only `recipe://callback` URLs to the active AppAuth external user-agent session. - Added `iosApp/Podfile` with `AppAuth` so the iOS shell has explicit pod integration alongside the existing KMP CocoaPods block. ## Task Commits 1. **Task 1: Implement iOS AppAuth OidcClient actual and CocoaPods bridge** - `ac9fc61` (feat) 2. **Task 2: Implement iOS Keychain store and callback wiring** - `88dc8d7` (feat) ## Files Created/Modified - `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` - AppAuth-iOS login, refresh, logout, AuthState archive/restore, and callback bridge. - `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt` - Keychain-backed opaque AuthState store with the required accessibility class. - `iosApp/Podfile` - iOS target Podfile declaring the `AppAuth` pod. - `iosApp/iosApp/Info.plist` - `CFBundleURLTypes` registration for the `recipe` custom URL scheme. - `iosApp/iosApp/iOSApp.swift` - SwiftUI `.onOpenURL` forwarding for `recipe://callback`. ## Decisions Made See frontmatter `key-decisions`. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Implemented `SecureAuthStateStore.ios.kt` during Task 1** - **Found during:** Task 1 verification - **Issue:** `./gradlew :composeApp:compileKotlinIosSimulatorArm64` cannot pass after adding only `OidcClient.ios.kt` because the common `SecureAuthStateStore` expect class also requires an iOS actual. - **Fix:** Added the Keychain-backed iOS secure store in the Task 1 commit, then Task 2 added the URL scheme and Swift callback wiring. - **Files modified:** `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt` - **Verification:** `./gradlew :composeApp:compileKotlinIosSimulatorArm64` - **Committed in:** `ac9fc61` **2. [Rule 3 - Blocking] Wrapped AppAuth-iOS secure archive in JSON** - **Found during:** Task 1 implementation - **Issue:** AppAuth-iOS 2.0.0 exposes `OIDAuthState` as `NSSecureCoding`; it does not expose the Android-style `serialize()` / JSON-deserialize API assumed by the plan. - **Fix:** Persisted a JSON wrapper containing the full `NSKeyedArchiver` secure archive of `OIDAuthState`, preserving the common opaque `authStateJson` contract while using AppAuth-iOS' supported persistence mechanism. - **Files modified:** `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` - **Verification:** `./gradlew :composeApp:compileKotlinIosSimulatorArm64` - **Committed in:** `ac9fc61` --- **Total deviations:** 2 auto-fixed (2 x Rule 3). **Impact on plan:** No auth behavior was reduced. Both fixes were required for the iOS target to compile against the actual AppAuth-iOS API and the existing common expect contracts. ## Known Stubs None. ## Threat Flags None beyond the plan's threat model. This plan intentionally touches the browser callback, Keychain storage, and Swift-to-KMP callback trust boundaries already listed in `02-05-PLAN.md`. ## Issues Encountered - `iosApp/Podfile` did not exist even though the plan listed it in `read_first`; it was created in Task 1. - A parallel `git add` attempt briefly hit Git's index lock. Staging was retried sequentially; no repository state was lost. - `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` passed as an extra confidence check and confirmed `IosAppAuthBridge.shared.resumeExternalUserAgentFlow(urlString:)` is exported to Swift. ## User Setup Required None for this plan. Real login still requires the Authentik provider configuration documented in `docs/authentik-setup.md`. ## Verification - Task 1 acceptance greps - PASS - Task 2 acceptance greps - PASS - `./gradlew :composeApp:compileKotlinIosSimulatorArm64` - PASS - Extra: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` - PASS - Token/logging scan - PASS; no `Logger`, `println`, or token/AuthState logging calls were added. ## Next Phase Readiness Plan 02-06 can consume the common `OidcClient` and `SecureAuthStateStore` on iOS. Plan 02-07 should still run real iOS/Authenik UAT for browser handoff, refresh across relaunch, and end-session behavior. ## Self-Check: PASSED - Created/modified files exist: all five plan-owned source/config files plus this summary were found. - Commits exist: `ac9fc61` and `88dc8d7` were found in git history. - Acceptance criteria: all Task 1 and Task 2 grep checks passed. - Plan-level verification: `./gradlew :composeApp:compileKotlinIosSimulatorArm64` passed. --- *Phase: 02-authentication-foundation* *Completed: 2026-04-28*