registered URL scheme matches redirect URI consumed by AppAuth
CFBundleURLSchemes|recipe
Implement the iOS OIDC and secure storage actuals.
Purpose: satisfy iOS-primary AUTH-01/AUTH-02/AUTH-04/AUTH-05 behind the common contracts from Plan 02-03 without mixing Android work into the same execution plan.
Output: iOS AppAuth OidcClient actual, iOS Keychain AuthState store, URL scheme registration, Swift callback wiring, and Podfile integration.
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md
@AGENTS.md
@iosApp/iosApp/Info.plist
@iosApp/iosApp/iOSApp.swift
Task 1: Implement iOS AppAuth OidcClient actual and CocoaPods bridge
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- iosApp/Podfile
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-05, D-06, D-09, D-16, D-19, D-20)
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, iosApp/Podfile
Implement iOS `actual class OidcClient` via AppAuth-iOS interop using `OIDAuthorizationService`, `OIDAuthState`, `OIDAuthorizationRequest`, token refresh/fresh-token helpers, and `OIDEndSessionRequest`. Use `suspendCancellableCoroutine` so cancellation cancels the current AppAuth request.
Request scopes exactly `openid`, `profile`, `email`, and `offline_access`. Serialize and deserialize the full `OIDAuthState` JSON blob per D-13. Refresh must use AppAuth fresh-token behavior and return updated AuthState JSON for AuthSession persistence. Logout must attempt RP-initiated end-session with `id_token_hint` when available; if end-session is unavailable or fails, surface no local-token-clearing responsibility here because AuthSession clears local state after calling logout.
Ensure AppAuth CocoaPod integration is present through the existing Gradle CocoaPods setup from Plan 02-01 and/or `iosApp/Podfile` as required by the repo's KMP CocoaPods wiring. Do not introduce an additional OIDC library.
./gradlew :composeApp:compileKotlinIosSimulatorArm64
- `grep -q 'OIDAuthorizationService' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'suspendCancellableCoroutine' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'OIDEndSessionRequest' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'AppAuth' iosApp/Podfile composeApp/build.gradle.kts`
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` exits 0
iOS AppAuth login, refresh, and logout compile behind the common OidcClient contract.
Task 2: Implement iOS Keychain store and callback wiring
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- iosApp/iosApp/Info.plist
- iosApp/iosApp/iOSApp.swift
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-09, D-13, D-14, D-15)
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift
Implement iOS `actual class SecureAuthStateStore` with Keychain read/write/delete for one opaque AuthState JSON string per app install. Use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` exactly per D-14; do not store AuthState in UserDefaults or plaintext files.
Add `CFBundleURLTypes` to `iosApp/iosApp/Info.plist` registering scheme `recipe`, matching redirect URI `recipe://callback`.
Add SwiftUI `.onOpenURL` or an app delegate bridge in `iOSApp.swift` that forwards incoming `recipe://callback` URLs to the current AppAuth external user-agent session held by the KMP iOS OidcClient bridge. Keep existing Koin initialization intact.
./gradlew :composeApp:compileKotlinIosSimulatorArm64
- `grep -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt`
- `! grep -R 'NSUserDefaults\\|UserDefaults' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth`
- `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist`
- `grep -q 'recipe' iosApp/iosApp/Info.plist`
- `grep -q 'onOpenURL\\|application(.*open' iosApp/iosApp/iOSApp.swift`
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` exits 0
iOS token storage is explicit and the custom URL callback is wired back into AppAuth.
<threat_model>
Trust Boundaries
Boundary
Description
system browser -> iOS app
Authorization code returns through custom URL scheme
iOS app -> Keychain
AuthState JSON containing refresh token is persisted
Swift shell -> KMP auth bridge
openURL callback crosses from SwiftUI into KMP/AppAuth flow state
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-02-05-01
Spoofing/Elevation
custom URL callback
mitigate
AppAuth handles state/nonce and PKCE S256; Info.plist byte-matches recipe://callback
T-02-05-02
Information Disclosure
iOS token store
mitigate
Keychain item uses kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; grep forbids UserDefaults in auth
T-02-05-03
Information Disclosure
AppAuth diagnostics
mitigate
iOS actual must not log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers
T-02-05-04
Spoofing
Swift callback bridge
mitigate
onOpenURL forwards only registered callback URLs to the active AppAuth session
</threat_model>
Run `./gradlew :composeApp:compileKotlinIosSimulatorArm64`.