Files
recipe/.planning/phases/02-authentication-foundation/02-05-SUMMARY.md
2026-04-29 21:07:49 +02:00

154 lines
7.4 KiB
Markdown

---
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*