Files
recipe/.planning/phases/02-authentication-foundation/02-03-PLAN.md

13 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-authentication-foundation 03 execute 2
02-01
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt
composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt
composeApp/src/androidMain/AndroidManifest.xml
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt
iosApp/iosApp/Info.plist
iosApp/iosApp/iOSApp.swift
iosApp/Podfile
composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt
composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt
true
AUTH-01
AUTH-02
AUTH-04
AUTH-05
truths artifacts key_links
iOS and Android login use AppAuth authorization-code flow with PKCE through system browser and recipe://callback
Requested scopes are exactly openid profile email offline_access
AuthState JSON is stored through explicit iOS Keychain and Android EncryptedSharedPreferences-backed stores
JVM target has DEV_AUTH_TOKEN dev stub; Wasm target throws NotImplementedError("Wasm OIDC: v2")
Logout platform clients support RP-initiated end-session and local store clearing
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt expect OIDC seam with suspend login/refresh/logout per D-01..D-04 expect class OidcClient
path provides contains
composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt Android explicit secure token storage per AUTH-02 EncryptedSharedPreferences
path provides contains
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt iOS Keychain storage with AfterFirstUnlockThisDeviceOnly per D-14 kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
path provides contains
iosApp/iosApp/Info.plist recipe URL scheme registration CFBundleURLSchemes
from to via pattern
composeApp/src/androidMain/AndroidManifest.xml composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt AppAuth redirect receiver for recipe://callback RedirectUriReceiverActivity|recipe
from to via pattern
iosApp/iosApp/iOSApp.swift composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt openURL forwards callback to current AppAuth external user-agent session onOpenURL|currentAuthorizationFlow
Implement the platform OIDC and secure storage boundary for mobile auth.

Purpose: satisfy AUTH-01/AUTH-02 platform requirements before AuthSession composes them into app state. Output: expect/actual OIDC client, explicit secure auth state store, URL callback registration, and platform stubs.

<execution_context> @/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md @/Users/rwilk/.codex/get-shit-done/templates/summary.md </execution_context>

@.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 @AGENTS.md @composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt @iosApp/iosApp/iOSApp.swift Task 1: Define OidcClient and secure store common contracts - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01..D-06, D-13..D-20) - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 1) composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt Create `OidcResult` sealed type with `Success(authStateJson: String, accessToken: String, idToken: String?, expiresAtEpochMillis: Long)`, `Cancelled`, `NetworkError`, and `AuthError`.
Create `expect class OidcClient` with `suspend fun login(): OidcResult`, `suspend fun refresh(authStateJson: String): OidcResult`, and `suspend fun logout(authStateJson: String): Unit`. The contract must state that native actuals use AppAuth and request scopes exactly `openid profile email offline_access`.

Create `expect class SecureAuthStateStore` with `fun read(): String?`, `fun write(authStateJson: String)`, and `fun clear()`. Add contract tests using a fake in-memory implementation to lock read/write/clear semantics; platform implementations compile in Task 2.
./gradlew :composeApp:jvmTest - `grep -q 'expect class OidcClient' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` - `grep -q 'openid profile email offline_access' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` - `grep -q 'expect class SecureAuthStateStore' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt` - `./gradlew :composeApp:jvmTest` exits 0 Common auth platform seams exist with testable store semantics and exact scope contract. Task 2: Implement Android and iOS AppAuth actuals with explicit secure storage - composeApp/build.gradle.kts - composeApp/src/androidMain/AndroidManifest.xml - iosApp/iosApp/Info.plist - iosApp/iosApp/iOSApp.swift - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-09, D-13, D-14, D-19, D-20) composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt, composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt, composeApp/src/androidMain/AndroidManifest.xml, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift, iosApp/Podfile Android: implement AppAuth-Android using `AuthorizationServiceConfiguration.fetchFromIssuer`, `AuthorizationRequest.Builder`, `ResponseTypeValues.CODE`, `setScopes("openid", "profile", "email", "offline_access")`, AppAuth PKCE defaults, `suspendCancellableCoroutine`, `AuthState.jsonSerializeString()`, `AuthState.jsonDeserialize(...)`, `performActionWithFreshTokens`, and `EndSessionRequest` when metadata exposes end-session. Register `net.openid.appauth.RedirectUriReceiverActivity` for scheme `recipe` host `callback`.
Android secure storage decision: use AndroidX Security Crypto `EncryptedSharedPreferences` behind `SecureAuthStateStore.android.kt` for AUTH-02 because the requirement explicitly calls out Android EncryptedSharedPreferences. Document in code comment that the dependency is deprecated upstream but isolated behind `SecureAuthStateStore`; do not use no-arg `Settings()` or ordinary `SharedPreferences` for auth tokens.

iOS: implement AppAuth-iOS via CocoaPods/interop using `OIDAuthorizationService`, `OIDAuthState`, `OIDAuthorizationRequest`, `OIDTokenRequest` refresh/fresh-token helpers, `OIDEndSessionRequest`, and `suspendCancellableCoroutine`. Register `CFBundleURLTypes` for `recipe`. Add SwiftUI `.onOpenURL` or app delegate bridge in `iOSApp.swift` to resume the current AppAuth flow.

iOS secure storage: implement Keychain read/write/delete with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. Persist the full AppAuth AuthState JSON blob per D-13.
./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 - `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt` - `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth` - `grep -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt` - `grep -q 'offline_access' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` - `grep -q 'recipe' composeApp/src/androidMain/AndroidManifest.xml` - `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist` - `./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0 Mobile targets compile with AppAuth login/refresh/logout and explicit secure AuthState persistence. Task 3: Add JVM and Wasm target actuals - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-02, D-03) - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt - composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt JVM actual reads `DEV_AUTH_TOKEN` from environment and returns `OidcResult.Success(authStateJson = "dev:$token", accessToken = token, idToken = null, expiresAtEpochMillis = Long.MAX_VALUE)` when present. If missing, return `OidcResult.AuthError("DEV_AUTH_TOKEN is not set")`; do not hardcode a usable bearer token.
Wasm actual throws exactly `NotImplementedError("Wasm OIDC: v2")` for login/refresh/logout per D-03.
./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs - `grep -q 'DEV_AUTH_TOKEN' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt` - `grep -q 'NotImplementedError("Wasm OIDC: v2")' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` - `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0 Secondary targets compile without expanding Phase 2 scope into real Desktop/Wasm OIDC.

<threat_model>

Trust Boundaries

Boundary Description
system browser -> app Authorization code returns through custom URL scheme
app process -> OS secure storage AuthState JSON containing refresh token is persisted
app -> Authentik Refresh and end-session requests exchange tokens with IdP

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-02-03-01 Spoofing/Elevation custom URL callback mitigate AppAuth handles state/nonce and PKCE S256; redirect URI byte-matched to recipe://callback
T-02-03-02 Information Disclosure Android token store mitigate Use EncryptedSharedPreferences behind SecureAuthStateStore; grep forbids no-arg Settings() in auth
T-02-03-03 Information Disclosure iOS token store mitigate Keychain item uses kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
T-02-03-04 Information Disclosure AppAuth diagnostics mitigate Do not log AuthState JSON, access tokens, refresh tokens, id tokens, or Authorization headers
T-02-03-05 Spoofing JVM dev stub accept Desktop is dev tool only; stub requires explicit DEV_AUTH_TOKEN and is not a release surface
</threat_model>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileKotlinWasmJs`.

<success_criteria> AUTH-01/AUTH-02 platform primitives exist: native AppAuth login/refresh/logout compiles, secure stores are explicit, and secondary target stubs match decisions. </success_criteria>

After completion, create `.planning/phases/02-authentication-foundation/02-03-SUMMARY.md`.