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