fix(02): split auth platform plans

This commit is contained in:
2026-04-27 21:07:18 +02:00
parent f0462cbca1
commit 0b01bc8bbb
6 changed files with 640 additions and 343 deletions

View File

@@ -73,7 +73,16 @@ Plans:
3. I tap "Wyloguj się"; the app returns to the login screen and the stored tokens are gone from Keychain/EncryptedSharedPreferences. 3. I tap "Wyloguj się"; the app returns to the login screen and the stored tokens are gone from Keychain/EncryptedSharedPreferences.
4. Calling `GET /api/v1/me` with a valid token returns my user record; the same call with a missing, expired, or wrong-audience token returns 401. 4. Calling `GET /api/v1/me` with a valid token returns my user record; the same call with a missing, expired, or wrong-audience token returns 401.
5. My user row exists in the server DB after my first successful login, keyed by the OIDC `sub` claim (no manual user creation needed). 5. My user row exists in the server DB after my first successful login, keyed by the OIDC `sub` claim (no manual user creation needed).
**Plans:** TBD **Plans:** 7 plans
Plans:
- [ ] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit
- [ ] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me`
- [ ] 02-03-PLAN.md — Common OIDC/store contracts, JVM/Wasm actuals, and store contract test
- [ ] 02-04-PLAN.md — Android AppAuth actual, Android secure AuthState store, and manifest callback
- [ ] 02-05-PLAN.md — iOS AppAuth actual, iOS Keychain store, URL scheme, Swift callback, and Podfile
- [ ] 02-06-PLAN.md — AuthSession state machine, bearer HTTP client, refresh/logout behavior, and Koin wiring
- [ ] 02-07-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT
**UI hint:** yes **UI hint:** yes
**Research flag:** yes **Research flag:** yes
@@ -88,14 +97,7 @@ Plans:
3. Once both users are in the same household, any household-scoped API call returns identical data regardless of which member made it. 3. Once both users are in the same household, any household-scoped API call returns identical data regardless of which member made it.
4. A crafted API request that puts a different `household_id` in the body is ignored — the server always derives `household_id` from the authenticated principal, not the payload. 4. A crafted API request that puts a different `household_id` in the body is ignored — the server always derives `household_id` from the authenticated principal, not the payload.
5. The server starts up and Flyway automatically applies `V1__init.sql` (or equivalent) in the correct order; restarting the server twice in a row is idempotent. 5. The server starts up and Flyway automatically applies `V1__init.sql` (or equivalent) in the correct order; restarting the server twice in a row is idempotent.
**Plans:** 5 plans **Plans:** TBD
Plans:
- [ ] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit
- [ ] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me`
- [ ] 02-03-PLAN.md — AppAuth platform actuals, callback registration, and secure token storage
- [ ] 02-04-PLAN.md — AuthSession state machine, bearer HTTP client, refresh/logout behavior, and Koin wiring
- [ ] 02-05-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT
**UI hint:** yes **UI hint:** yes
**Research flag:** no **Research flag:** no
@@ -220,7 +222,7 @@ Plans:
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 1. Project Infrastructure & Module Wiring | 7/7 | Complete | 2026-04-24 | | 1. Project Infrastructure & Module Wiring | 7/7 | Complete | 2026-04-24 |
| 2. Authentication Foundation | 0/5 | Planned | - | | 2. Authentication Foundation | 0/7 | Planned | - |
| 3. Households, Membership & Server Data Foundation | 0/0 | Not started | - | | 3. Households, Membership & Server Data Foundation | 0/0 | Not started | - |
| 4. Sync Engine Skeleton | 0/0 | Not started | - | | 4. Sync Engine Skeleton | 0/0 | Not started | - |
| 5. Recipe Catalog (Read Path) | 0/0 | Not started | - | | 5. Recipe Catalog (Read Path) | 0/0 | Not started | - |

View File

@@ -8,14 +8,6 @@ files_modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt - 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/OidcResult.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.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/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt
- composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt - composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt
@@ -25,47 +17,44 @@ autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05] requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves: must_haves:
truths: truths:
- "iOS and Android login use AppAuth authorization-code flow with PKCE through system browser and recipe://callback" - "Common auth code compiles against one expect OidcClient seam with login, refresh, and logout"
- "Requested scopes are exactly openid profile email offline_access" - "Requested native OIDC scopes are documented in the common contract as exactly openid profile email offline_access"
- "AuthState JSON is stored through explicit iOS Keychain and Android EncryptedSharedPreferences-backed stores" - "Every configured non-mobile target has actuals so JVM and Wasm builds compile"
- "Every configured KMP target has a SecureAuthStateStore actual, including JVM and Wasm stubs" - "JVM target uses explicit DEV_AUTH_TOKEN dev behavior and does not hardcode a usable bearer token"
- "JVM target has DEV_AUTH_TOKEN dev stub; Wasm target throws NotImplementedError(\"Wasm OIDC: v2\")" - "Wasm target preserves the v2 boundary with NotImplementedError(\"Wasm OIDC: v2\")"
- "Logout platform clients support RP-initiated end-session and local store clearing" - "SecureAuthStateStore read/write/clear semantics are locked by a common contract test"
artifacts: artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt"
provides: "expect OIDC seam with suspend login/refresh/logout per D-01..D-04" provides: "expect OIDC seam with suspend login/refresh/logout per D-01..D-04"
contains: "expect class OidcClient" contains: "expect class OidcClient"
- path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt"
provides: "Android explicit secure token storage per AUTH-02" provides: "common OIDC result model consumed by AuthSession and LoginViewModel"
contains: "EncryptedSharedPreferences" contains: "sealed"
- path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt"
provides: "iOS Keychain storage with AfterFirstUnlockThisDeviceOnly per D-14" provides: "expect secure AuthState JSON store per D-13..D-15"
contains: "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly" contains: "expect class SecureAuthStateStore"
- path: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt" - path: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt"
provides: "JVM dev-only in-memory AuthState store actual so desktop tests compile" provides: "JVM dev-only token stub per D-02"
contains: "actual class SecureAuthStateStore" contains: "DEV_AUTH_TOKEN"
- path: "composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt" - path: "composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt"
provides: "Wasm non-persistent AuthState store actual so wasm target compiles while OIDC remains v2" provides: "Wasm v2 stub per D-03"
contains: "actual class SecureAuthStateStore" contains: "NotImplementedError(\"Wasm OIDC: v2\")"
- path: "iosApp/iosApp/Info.plist"
provides: "recipe URL scheme registration"
contains: "CFBundleURLSchemes"
key_links: key_links:
- from: "composeApp/src/androidMain/AndroidManifest.xml" - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt"
to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt" to: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt"
via: "AppAuth redirect receiver for recipe://callback" via: "actual class implements common suspend login/refresh/logout contract"
pattern: "RedirectUriReceiverActivity|recipe" pattern: "actual class OidcClient"
- from: "iosApp/iosApp/iOSApp.swift" - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt"
to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt" to: "composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt"
via: "openURL forwards callback to current AppAuth external user-agent session" via: "contract test validates read/write/clear behavior without platform secure storage"
pattern: "onOpenURL|currentAuthorizationFlow" pattern: "read.*write.*clear"
--- ---
<objective> <objective>
Implement the platform OIDC and secure storage boundary for mobile auth. Define the common OIDC and AuthState storage contracts, plus JVM/Wasm actuals that keep secondary targets compiling.
Purpose: satisfy AUTH-01/AUTH-02 platform requirements before `AuthSession` composes them into app state. Purpose: downstream mobile plans implement platform AppAuth behind a stable seam, while AuthSession can be built without platform-specific APIs.
Output: expect/actual OIDC client, explicit secure auth state store, URL callback registration, and platform stubs. Output: common auth contracts, JVM dev actuals, Wasm v2 stubs, and a common store contract test.
</objective> </objective>
<execution_context> <execution_context>
@@ -82,26 +71,35 @@ Output: expect/actual OIDC client, explicit secure auth state store, URL callbac
@.planning/phases/02-authentication-foundation/02-PATTERNS.md @.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md @.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@AGENTS.md @AGENTS.md
@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt @composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
@iosApp/iosApp/iOSApp.swift @composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
</context> </context>
<tasks> <tasks>
<task type="auto"> <task type="auto" tdd="true">
<name>Task 1: Define OidcClient and secure store common contracts</name> <name>Task 1: Define common OIDC and secure store contracts</name>
<read_first> <read_first>
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt - 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-CONTEXT.md (D-01..D-06, D-13..D-20)
- .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 1) - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 1 and secure storage recommendation)
</read_first> </read_first>
<files>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</files> <files>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</files>
<behavior>
- `OidcResult.Success` carries `authStateJson`, `accessToken`, nullable `idToken`, and `expiresAtEpochMillis`.
- `OidcClient` exposes suspend `login()`, `refresh(authStateJson)`, and `logout(authStateJson)`.
- Common contract text states native actuals use AppAuth and request exactly `openid profile email offline_access` per D-01/D-06.
- `SecureAuthStateStore` exposes `read()`, `write(authStateJson)`, and `clear()`.
- Contract test proves write overwrites previous value, read returns latest value, and clear removes it.
</behavior>
<action> <action>
Create `OidcResult` sealed type with `Success(authStateJson: String, accessToken: String, idToken: String?, expiresAtEpochMillis: Long)`, `Cancelled`, `NetworkError`, and `AuthError`. Create `OidcResult` as a sealed interface or sealed class with `Success(authStateJson: String, accessToken: String, idToken: String?, expiresAtEpochMillis: Long)`, `Cancelled`, `NetworkError`, and `AuthError(message: String, cause: Throwable? = null)`.
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 OidcClient` with `suspend fun login(): OidcResult`, `suspend fun refresh(authStateJson: String): OidcResult`, and `suspend fun logout(authStateJson: String): Unit`. The common KDoc must pin D-01, D-04, D-06, D-16, D-19, and D-20: native implementations use AppAuth, bridge callbacks with `suspendCancellableCoroutine`, request exactly `openid profile email offline_access`, refresh through AppAuth fresh-token APIs, and logout through RP-initiated end-session before local clear.
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. Create `expect class SecureAuthStateStore` with `fun read(): String?`, `fun write(authStateJson: String)`, and `fun clear()`. The KDoc must state it persists the full AppAuth AuthState JSON blob per D-13 and must not use no-arg insecure settings for tokens.
Add `SecureAuthStateStoreContractTest` using a fake in-memory implementation in commonTest to lock the store behavior. Keep this test platform-free; Android and iOS secure implementations are created in Plans 02-04 and 02-05.
</action> </action>
<verify> <verify>
<automated>./gradlew :composeApp:jvmTest</automated> <automated>./gradlew :composeApp:jvmTest</automated>
@@ -110,48 +108,15 @@ Output: expect/actual OIDC client, explicit secure auth state store, URL callbac
- `grep -q 'expect class OidcClient' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` - `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 '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` - `grep -q 'expect class SecureAuthStateStore' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt`
- `grep -q 'AuthState JSON' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt`
- `grep -q 'clear' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt`
- `./gradlew :composeApp:jvmTest` exits 0 - `./gradlew :composeApp:jvmTest` exits 0
</acceptance_criteria> </acceptance_criteria>
<done>Common auth platform seams exist with testable store semantics and exact scope contract.</done> <done>Common auth seams exist with exact scope/logout/storage semantics and testable store behavior.</done>
</task> </task>
<task type="auto"> <task type="auto">
<name>Task 2: Implement Android and iOS AppAuth actuals with explicit secure storage</name> <name>Task 2: Add JVM and Wasm actuals</name>
<read_first>
- 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)
</read_first>
<files>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</files>
<action>
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.
</action>
<verify>
<automated>./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Mobile targets compile with AppAuth login/refresh/logout and explicit secure AuthState persistence.</done>
</task>
<task type="auto">
<name>Task 3: Add JVM and Wasm target actuals</name>
<read_first> <read_first>
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-02, D-03) - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-02, D-03)
- composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
@@ -159,13 +124,11 @@ Output: expect/actual OIDC client, explicit secure auth state store, URL callbac
</read_first> </read_first>
<files>composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt, composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt</files> <files>composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt, composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt</files>
<action> <action>
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. JVM actual reads `DEV_AUTH_TOKEN` from the 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.
JVM `SecureAuthStateStore` actual must compile for desktop dev/tests without pretending to be production secure storage. Implement `actual class SecureAuthStateStore` with a private nullable in-memory `authStateJson` property and exact methods `read()`, `write(authStateJson: String)`, and `clear()`. This store is dev-only and process-local; do not use it for mobile targets. JVM `SecureAuthStateStore` actual must compile for desktop dev/tests without pretending to be production secure storage. Implement `actual class SecureAuthStateStore` with a private nullable in-memory `authStateJson` property and exact methods `read()`, `write(authStateJson: String)`, and `clear()`.
Wasm actual throws exactly `NotImplementedError("Wasm OIDC: v2")` for login/refresh/logout per D-03. Wasm `OidcClient` actual throws exactly `NotImplementedError("Wasm OIDC: v2")` for login, refresh, and logout per D-03. Wasm `SecureAuthStateStore` actual must also exist so `:composeApp:compileKotlinWasmJs` compiles; implement the same non-persistent in-memory store shape used by JVM.
Wasm `SecureAuthStateStore` actual must also exist so `:composeApp:compileKotlinWasmJs` compiles. Implement `actual class SecureAuthStateStore` with the same private nullable in-memory `authStateJson` property and `read()`, `write(authStateJson: String)`, `clear()` methods. Because Wasm OIDC itself throws `NotImplementedError("Wasm OIDC: v2")`, this store is non-persistent and only satisfies the KMP actual contract.
</action> </action>
<verify> <verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs</automated> <automated>./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs</automated>
@@ -177,7 +140,7 @@ Output: expect/actual OIDC client, explicit secure auth state store, URL callbac
- `grep -q 'actual class SecureAuthStateStore' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt` - `grep -q 'actual class SecureAuthStateStore' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt`
- `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0 - `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0
</acceptance_criteria> </acceptance_criteria>
<done>Secondary targets compile without expanding Phase 2 scope into real Desktop/Wasm OIDC.</done> <done>Secondary targets compile without expanding Phase 2 into real Desktop or Wasm OIDC.</done>
</task> </task>
</tasks> </tasks>
@@ -187,27 +150,26 @@ Output: expect/actual OIDC client, explicit secure auth state store, URL callbac
| Boundary | Description | | Boundary | Description |
|----------|-------------| |----------|-------------|
| system browser -> app | Authorization code returns through custom URL scheme | | common auth contract -> platform actuals | Common AuthSession code delegates browser/token behavior to target-specific implementations |
| app process -> OS secure storage | AuthState JSON containing refresh token is persisted | | app process -> dev environment | JVM dev stub reads bearer token from `DEV_AUTH_TOKEN` |
| app -> Authentik | Refresh and end-session requests exchange tokens with IdP | | app process -> non-persistent stubs | JVM/Wasm stores satisfy contracts without claiming production secure storage |
## STRIDE Threat Register ## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan | | 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-01 | Spoofing/Elevation | OidcClient contract | mitigate | Common KDoc pins AppAuth, PKCE-compatible native flow, exact scopes, state/nonce ownership, and RP-initiated logout semantics for platform plans |
| T-02-03-02 | Information Disclosure | Android token store | mitigate | Use EncryptedSharedPreferences behind `SecureAuthStateStore`; grep forbids no-arg `Settings()` in auth | | T-02-03-02 | Information Disclosure | SecureAuthStateStore contract | mitigate | Contract states full AuthState JSON must use explicit secure platform storage; Android/iOS plans implement the secure actuals |
| T-02-03-03 | Information Disclosure | iOS token store | mitigate | Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | | T-02-03-03 | Spoofing | JVM dev stub | accept | Desktop is dev tool only; stub requires explicit `DEV_AUTH_TOKEN` and never hardcodes a usable bearer token |
| 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-04 | Scope Creep | Wasm OIDC | accept | Wasm actual throws `NotImplementedError("Wasm OIDC: v2")` per D-03 and does not implement browser OIDC in Phase 2 |
| 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> </threat_model>
<verification> <verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileKotlinWasmJs`. Run `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs`.
</verification> </verification>
<success_criteria> <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. Common OIDC/storage contracts exist below the file-count threshold, JVM/Wasm targets compile, and downstream Android/iOS/AuthSession plans can depend on stable auth seams.
</success_criteria> </success_criteria>
<output> <output>

View File

@@ -3,53 +3,46 @@ phase: 02-authentication-foundation
plan: 04 plan: 04
type: execute type: execute
wave: 3 wave: 3
depends_on: [02-02, 02-03] depends_on: [02-01, 02-03]
files_modified: files_modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt - composeApp/src/androidMain/AndroidManifest.xml
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
autonomous: true autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05] requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves: must_haves:
truths: truths:
- "AuthSession starts in Loading and restores persisted AuthState JSON before deciding Authenticated or Unauthenticated" - "Android login uses AppAuth authorization-code flow with PKCE through system browser and recipe://callback"
- "Authenticated state contains User and householdId = null in Phase 2" - "Android requested scopes are exactly openid profile email offline_access"
- "Authenticated API calls get fresh access tokens proactively and Ktor bearer auth can reactively refresh on 401" - "Android persists full AppAuth AuthState JSON through EncryptedSharedPreferences-backed SecureAuthStateStore"
- "Refresh invalid_grant transitions silently to Unauthenticated" - "Android refresh uses AppAuth fresh-token behavior and persists updated AuthState JSON"
- "logout() attempts RP end-session and clears local AuthState even if end-session fails" - "Android logout uses AppAuth end-session when metadata exposes an endpoint"
- "AuthSession is a Koin singleton in authModule and wired into appModule"
artifacts: artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt" - path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt"
provides: "Loading/Unauthenticated/Authenticated(user, householdId?) state model per D-28" provides: "Android AppAuth actual per D-01, D-04, D-16, D-19, D-20"
contains: "householdId" contains: "AuthorizationService"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" - path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt"
provides: "StateFlow auth owner per D-29" provides: "Android explicit secure token storage per AUTH-02"
exports: ["AuthSession"] contains: "EncryptedSharedPreferences"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt" - path: "composeApp/src/androidMain/AndroidManifest.xml"
provides: "Ktor client bearer auth with refreshTokens per D-17" provides: "recipe://callback registration for AppAuth redirect receiver"
contains: "refreshTokens" contains: "RedirectUriReceiverActivity"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt"
provides: "GET /api/v1/me client returning MeResponse"
key_links: key_links:
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" - from: "composeApp/src/androidMain/AndroidManifest.xml"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt" to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt"
via: "login/refresh/logout delegate to platform AppAuth seam" via: "AppAuth redirect receiver for recipe://callback"
pattern: "oidcClient" pattern: "RedirectUriReceiverActivity|recipe"
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" - from: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt" to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt"
via: "after login/restore, fetch /api/v1/me to build Authenticated(user, null)" via: "AuthState JSON returned by AppAuth is what AuthSession persists through the store"
pattern: "meClient" pattern: "jsonSerializeString|jsonDeserialize"
--- ---
<objective> <objective>
Build the common client auth runtime: `AuthSession`, authenticated Ktor client, `/api/v1/me` client, and Koin wiring. Implement the Android OIDC and secure storage actuals.
Purpose: compose platform OIDC/storage from Plan 03 with server `/api/v1/me` from Plan 02 into persistent app session behavior. Purpose: satisfy Android's side of AUTH-01/AUTH-02/AUTH-04/AUTH-05 behind the common contracts from Plan 02-03 without mixing iOS work into the same execution plan.
Output: tested common auth state machine and DI module. Output: Android AppAuth OidcClient actual, Android secure AuthState store, and Android callback manifest registration.
</objective> </objective>
<execution_context> <execution_context>
@@ -64,98 +57,74 @@ Output: tested common auth state machine and DI module.
@.planning/phases/02-authentication-foundation/02-RESEARCH.md @.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md @.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md @.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-02-SUMMARY.md @.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md @.planning/phases/02-authentication-foundation/02-03-SUMMARY.md
@AGENTS.md @AGENTS.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt @composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt
@composeApp/src/androidMain/AndroidManifest.xml
</context> </context>
<tasks> <tasks>
<task type="auto" tdd="true"> <task type="auto">
<name>Task 1: Write AuthSession state-machine tests</name> <name>Task 1: Implement Android AppAuth OidcClient actual</name>
<read_first> <read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (AuthSessionTest)
</read_first>
<files>composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt</files>
<behavior>
- Empty store initializes `Loading -> Unauthenticated`.
- Successful login writes AuthState JSON, calls `/api/v1/me`, and emits `Authenticated(user, householdId = null)`.
- Existing store refreshes before `/api/v1/me` and emits Authenticated without login.
- Refresh `invalid_grant` or AuthError clears store and emits Unauthenticated without UI error.
- Logout calls `OidcClient.logout(authStateJson)` then clears store and emits Unauthenticated even when logout throws.
- Login cancelled maps to a result the UI can render as cancelled.
</behavior>
<action>
Create fakes for `OidcClient`, `SecureAuthStateStore`, and `MeClient`. Write tests for the exact behaviors above before production implementation. Keep tests in commonTest and avoid platform AppAuth classes.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'invalid_grant\\|AuthError' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
- `grep -q 'householdId' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
- After Task 2, `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0
</acceptance_criteria>
<done>State-machine tests cover AUTH-04/AUTH-05 and validation Wave 0 requirements.</done>
</task>
<task type="auto">
<name>Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client</name>
<read_first>
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-05, D-06, D-09, D-16, D-19, D-20)
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-16, D-17, D-18, D-28, D-29)
</read_first> </read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt</files> <files>composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt</files>
<action> <action>
Create sealed `AuthState` with `Loading`, `Unauthenticated`, `Authenticated(user: User, householdId: HouseholdId? = null)` and `typealias HouseholdId = String`. Implement Android `actual class OidcClient` using AppAuth-Android. Use `AuthorizationServiceConfiguration.fetchFromIssuer`, `AuthorizationRequest.Builder`, `ResponseTypeValues.CODE`, `setScopes("openid", "profile", "email", "offline_access")`, AppAuth PKCE defaults, and `suspendCancellableCoroutine` so cancellation cancels the underlying AppAuth request.
Implement `MeClient.getMe(accessToken: String? = null)` calling `GET ${Constants.API_BASE_URL}/api/v1/me`, decoding `MeResponse`, and mapping to `User`. If `accessToken` is supplied for tests/simple calls, attach `Authorization: Bearer <token>` without logging it. Token exchange and refresh must serialize/deserialize the AppAuth `AuthState` JSON with `AuthState.jsonSerializeString()` and `AuthState.jsonDeserialize(...)`. Refresh must use `performActionWithFreshTokens` so updated AuthState is persisted by AuthSession. Logout must build and execute `EndSessionRequest` when the discovery metadata exposes an end-session endpoint; if unavailable, return without throwing so AuthSession can still clear local state per D-19.
Implement `AuthHttpClient.create(authSession)` using Ktor Client `Auth { bearer { loadTokens { ... }; refreshTokens { ... }; sendWithoutRequest { request.url.host == Url(Constants.API_BASE_URL).host } } }`, ContentNegotiation JSON, and logging that redacts token-bearing headers. Map user cancellation to `OidcResult.Cancelled`, network failures to `OidcResult.NetworkError`, and token/auth failures to `OidcResult.AuthError`. Never log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers.
Implement `AuthSession` with `state: StateFlow<AuthState>`, `initialize()`, `login()`, `logout()`, `getAccessToken()`, `currentBearerTokens()`, and `refreshBearerTokens()`. `initialize()` reads stored AuthState JSON, refreshes with AppAuth, persists updated JSON, calls `/api/v1/me`, and emits `Authenticated(user, null)`. On refresh failure, clear the store and emit Unauthenticated silently. On logout, call `OidcClient.logout(storedJson)` first, then always clear.
</action> </action>
<verify> <verify>
<automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated> <automated>./gradlew :composeApp:compileDebugKotlinAndroid</automated>
</verify> </verify>
<acceptance_criteria> <acceptance_criteria>
- `grep -q 'StateFlow<AuthState>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt` - `grep -q 'AuthorizationServiceConfiguration.fetchFromIssuer' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `grep -q 'refreshTokens' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` - `grep -q 'setScopes("openid", "profile", "email", "offline_access")' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `grep -q 'sendWithoutRequest' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` - `grep -q 'suspendCancellableCoroutine' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `! grep -R 'Authorization.*\\$' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth` - `grep -q 'performActionWithFreshTokens' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0 - `grep -q 'EndSessionRequest' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- `./gradlew :composeApp:compileDebugKotlinAndroid` exits 0
</acceptance_criteria> </acceptance_criteria>
<done>Common auth runtime passes the state-machine tests and supports transparent refresh.</done> <done>Android AppAuth login, refresh, and logout compile behind the common OidcClient contract.</done>
</task> </task>
<task type="auto"> <task type="auto">
<name>Task 3: Wire authModule into Koin</name> <name>Task 2: Implement Android secure AuthState store and callback manifest</name>
<read_first> <read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt - composeApp/src/androidMain/AndroidManifest.xml
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-09, D-13, D-15)
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-29) - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Android secure storage correction)
</read_first> </read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt</files> <files>composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt, composeApp/src/androidMain/AndroidManifest.xml</files>
<action> <action>
Create `authModule = module { ... }` providing singleton `SecureAuthStateStore`, `OidcClient`, `MeClient`, `AuthSession`, and auth-related ViewModels only if their classes already exist. Wire `appModule` to include auth definitions without starting Koin from composables. If target-specific constructors need Android context/activity, use Koin platform APIs already available in Phase 1 Android bootstrap. Implement Android `actual class SecureAuthStateStore` using AndroidX Security Crypto `EncryptedSharedPreferences`. Store one opaque AuthState JSON string per app install under a private key. Add a short code comment noting AndroidX Security Crypto deprecation is contained behind this abstraction because AUTH-02 explicitly calls for Android EncryptedSharedPreferences in v1.
Do not use no-arg `Settings()`, ordinary `SharedPreferences`, or plaintext file storage for auth tokens.
Register AppAuth redirect handling in `composeApp/src/androidMain/AndroidManifest.xml` with `net.openid.appauth.RedirectUriReceiverActivity` and an intent filter for scheme `recipe` and host `callback`, matching D-09 exactly (`recipe://callback`).
</action> </action>
<verify> <verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated> <automated>./gradlew :composeApp:compileDebugKotlinAndroid</automated>
</verify> </verify>
<acceptance_criteria> <acceptance_criteria>
- `grep -q 'val authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt` - `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
- `grep -q 'authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` - `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth`
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0 - `! grep -R 'getSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
- `grep -q 'RedirectUriReceiverActivity' composeApp/src/androidMain/AndroidManifest.xml`
- `grep -q 'android:scheme="recipe"' composeApp/src/androidMain/AndroidManifest.xml`
- `grep -q 'android:host="callback"' composeApp/src/androidMain/AndroidManifest.xml`
- `./gradlew :composeApp:compileDebugKotlinAndroid` exits 0
</acceptance_criteria> </acceptance_criteria>
<done>AuthSession and collaborators are available as Koin singletons for the UI gate.</done> <done>Android token storage is explicit and the custom URL callback is registered for AppAuth.</done>
</task> </task>
</tasks> </tasks>
@@ -165,27 +134,26 @@ Output: tested common auth state machine and DI module.
| Boundary | Description | | Boundary | Description |
|----------|-------------| |----------|-------------|
| AuthSession -> server | Access token attached to `/api/v1/me` | | system browser -> Android app | Authorization code returns through custom URL scheme |
| AuthSession -> secure store | Refresh-capable AuthState JSON persists across app launches | | Android app -> OS secure storage | AuthState JSON containing refresh token is persisted |
| AuthSession -> UI | Auth failures influence rendered state and messages | | Android app -> Authentik | Refresh and end-session requests exchange tokens with IdP |
## STRIDE Threat Register ## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan | | Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------| |-----------|----------|-----------|-------------|-----------------|
| T-02-04-01 | Information Disclosure | AuthHttpClient logging | mitigate | Redact Authorization and never log token values | | T-02-04-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; Android manifest byte-matches `recipe://callback` |
| T-02-04-02 | Information Disclosure | AuthSession logout | mitigate | Always clear stored AuthState after logout attempt, including end-session failure | | T-02-04-02 | Information Disclosure | Android token store | mitigate | Use EncryptedSharedPreferences behind `SecureAuthStateStore`; grep forbids no-arg `Settings()` and plaintext SharedPreferences in auth |
| T-02-04-03 | Denial of Service | refresh path | mitigate | Proactive refresh before calls and Ktor bearer reactive refresh on 401 | | T-02-04-03 | Information Disclosure | AppAuth diagnostics | mitigate | Android actual must not log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers |
| T-02-04-04 | Spoofing | `/api/v1/me` response | mitigate | Authenticated state is built only from server `MeResponse`, not client-decoded token claims | | T-02-04-04 | Denial of Service | refresh path | mitigate | Use AppAuth `performActionWithFreshTokens` so expiry refresh is handled before authenticated calls |
| T-02-04-05 | Repudiation | silent invalid_grant | accept | Silent return to login is a locked UX decision D-18; auth warnings may log without secrets |
</threat_model> </threat_model>
<verification> <verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`. Run `./gradlew :composeApp:compileDebugKotlinAndroid`.
</verification> </verification>
<success_criteria> <success_criteria>
AUTH-04 and AUTH-05 work at the session layer: persisted tokens restore, refresh failures clear state, and logout wipes recoverable refresh tokens. Android AppAuth login/refresh/logout and Android secure AuthState persistence compile independently below the file-count threshold.
</success_criteria> </success_criteria>
<output> <output>

View File

@@ -2,54 +2,55 @@
phase: 02-authentication-foundation phase: 02-authentication-foundation
plan: 05 plan: 05
type: execute type: execute
wave: 4 wave: 3
depends_on: [02-04] depends_on: [02-01, 02-03]
files_modified: files_modified:
- composeApp/src/commonMain/composeResources/values/strings.xml - composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt - composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt - iosApp/iosApp/Info.plist
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt - iosApp/iosApp/iOSApp.swift
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt - iosApp/Podfile
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt autonomous: true
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt
autonomous: false
requirements: [AUTH-01, AUTH-04, AUTH-05]
must_haves: must_haves:
truths: truths:
- "Fresh launch in Loading shows SplashScreen with Recipe wordmark and progress indicator" - "iOS login uses AppAuth authorization-code flow with PKCE through system browser and recipe://callback"
- "Unauthenticated state shows LoginScreen with Polish Authentik sign-in button" - "iOS requested scopes are exactly openid profile email offline_access"
- "Login errors render inline below the button and retry clears stale error" - "iOS persists full AppAuth AuthState JSON in Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly"
- "Authenticated state shows Witaj, {displayName}! and Wyloguj się" - "SwiftUI callback wiring forwards recipe://callback to the current AppAuth flow"
- "Wyloguj się returns to LoginScreen through AuthSession.logout()" - "iOS logout uses AppAuth end-session when metadata exposes an endpoint"
- "All Phase 2 user-facing strings come from Compose Resources"
artifacts: artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt" - path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt"
provides: "Auth gate rendering Splash/Login/PostLogin by AuthState" provides: "iOS AppAuth actual per D-01, D-04, D-16, D-19, D-20"
contains: "when" contains: "OIDAuthorizationService"
- path: "composeApp/src/commonMain/composeResources/values/strings.xml" - path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt"
provides: "auth_app_name/auth_sign_in_button/auth_sign_out_button/auth_welcome_format/auth_error_*" provides: "iOS Keychain storage per D-14"
contains: "auth_sign_in_button" contains: "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt" - path: "iosApp/iosApp/Info.plist"
provides: "UI-SPEC login layout and inline error state" provides: "recipe URL scheme registration"
contains: "auth_sign_in_button" contains: "CFBundleURLSchemes"
- path: "iosApp/iosApp/iOSApp.swift"
provides: "SwiftUI openURL callback forwarding to AppAuth"
contains: "onOpenURL"
- path: "iosApp/Podfile"
provides: "AppAuth CocoaPod integration if required by chosen KMP CocoaPods setup"
contains: "AppAuth"
key_links: key_links:
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt" - from: "iosApp/iosApp/iOSApp.swift"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt"
via: "collectAsState over AuthSession.state" via: "openURL forwards callback to current AppAuth external user-agent session"
pattern: "collectAsState" pattern: "onOpenURL|currentAuthorizationFlow"
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt" - from: "iosApp/iosApp/Info.plist"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt"
via: "onSignOutClick delegates to logout" via: "registered URL scheme matches redirect URI consumed by AppAuth"
pattern: "logout" pattern: "CFBundleURLSchemes|recipe"
--- ---
<objective> <objective>
Deliver the user-facing Phase 2 auth experience and final validation gate. Implement the iOS OIDC and secure storage actuals.
Purpose: make end-to-end auth observable: login button, loading screen, welcome confirmation, logout button, and manual iOS Authentik UAT. 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: auth screens, auth gate, resource strings, UI ViewModels, and validation checklist execution. Output: iOS AppAuth OidcClient actual, iOS Keychain AuthState store, URL scheme registration, Swift callback wiring, and Podfile integration.
</objective> </objective>
<execution_context> <execution_context>
@@ -60,111 +61,78 @@ Output: auth screens, auth gate, resource strings, UI ViewModels, and validation
<context> <context>
@.planning/PROJECT.md @.planning/PROJECT.md
@.planning/REQUIREMENTS.md @.planning/REQUIREMENTS.md
@.planning/ROADMAP.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md @.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-UI-SPEC.md @.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md @.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md @.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-04-SUMMARY.md @.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md
@AGENTS.md @AGENTS.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt @iosApp/iosApp/Info.plist
@iosApp/iosApp/iOSApp.swift
</context> </context>
<tasks> <tasks>
<task type="auto" tdd="true"> <task type="auto">
<name>Task 1: Add Compose Resources, theme seed, and ViewModel tests</name> <name>Task 1: Implement iOS AppAuth OidcClient actual and CocoaPods bridge</name>
<read_first> <read_first>
- .planning/phases/02-authentication-foundation/02-UI-SPEC.md - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.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)
</read_first> </read_first>
<files>composeApp/src/commonMain/composeResources/values/strings.xml, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt</files> <files>composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, iosApp/Podfile</files>
<behavior>
- String keys exist with exact Polish scaffold copy from UI-SPEC.
- `RecipeTheme` uses Material 3 light/dark schemes with primary seed `#3B6939` / dark variant `#A2D597`.
- LoginViewModel maps cancelled/network/unknown auth failures to the correct string resource keys.
- Starting a new login clears previous inline error and sets loading.
</behavior>
<action> <action>
Create `strings.xml` keys: `auth_app_name`, `auth_sign_in_button`, `auth_sign_out_button`, `auth_welcome_format`, `auth_error_cancelled`, `auth_error_network`, `auth_error_unknown` with exact UI-SPEC copy. 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.
Add `RecipeTheme(content)` with `lightColorScheme(primary = Color(0xFF3B6939))`, `darkColorScheme(primary = Color(0xFFA2D597))`, `isSystemInDarkTheme()`, and Material 3 typography defaults. Do not add Haze, blur, images, icons, Scaffold, or marketing copy. 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.
Write `LoginViewModelTest` against a fake `AuthSession` result interface before implementing the ViewModel in Task 2. 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.
</action> </action>
<verify> <verify>
<automated>./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"</automated> <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify> </verify>
<acceptance_criteria> <acceptance_criteria>
- `grep -q 'name="auth_sign_in_button">Zaloguj się przez Authentik' composeApp/src/commonMain/composeResources/values/strings.xml` - `grep -q 'OIDAuthorizationService' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q '0xFF3B6939' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` - `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- `grep -q 'auth_error_cancelled' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt` - `grep -q 'suspendCancellableCoroutine' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- After Task 2, `./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"` exits 0 - `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
</acceptance_criteria> </acceptance_criteria>
<done>Resource and theme foundations match UI-SPEC and login error mapping is tested.</done> <done>iOS AppAuth login, refresh, and logout compile behind the common OidcClient contract.</done>
</task> </task>
<task type="auto"> <task type="auto">
<name>Task 2: Implement auth screens, ViewModels, and App auth gate</name> <name>Task 2: Implement iOS Keychain store and callback wiring</name>
<read_first> <read_first>
- .planning/phases/02-authentication-foundation/02-UI-SPEC.md (Component Inventory, Layout Contract, Auth Gate Routing Contract) - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt - iosApp/iosApp/Info.plist
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt - iosApp/iosApp/iOSApp.swift
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-09, D-13, D-14, D-15)
</read_first> </read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt</files> <files>composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift</files>
<action> <action>
Replace template `App()` body with `RecipeTheme { val authState by authSession.state.collectAsState(); when(authState) { Loading -> SplashScreen(); Unauthenticated -> LoginScreen(koinViewModel()); Authenticated -> PostLoginPlaceholderScreen(user, koinViewModel()) } }`. State changes drive recomposition; no manual navigation or Scaffold. 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.
Implement `SplashScreen`, `LoginScreen`, and `PostLoginPlaceholderScreen` exactly from UI-SPEC: centered column, `safeContentPadding`, horizontal 16.dp, displaySmall wordmark, Login button with loading indicator, inline bodyLarge error text below button, welcome `headlineSmall`, logout `OutlinedButton`. All strings must use `stringResource(Res.string.*)`. Add `CFBundleURLTypes` to `iosApp/iosApp/Info.plist` registering scheme `recipe`, matching redirect URI `recipe://callback`.
Implement `LoginViewModel` with method `onSignInClick()` and immutable `LoginScreenState(isLoading: Boolean, errorKey: StringResource?)`. Implement `PostLoginViewModel.onSignOutClick()` delegating to `AuthSession.logout()`. Register ViewModels in `authModule` using existing Koin Compose ViewModel pattern. 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.
</action> </action>
<verify> <verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated> <automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify> </verify>
<acceptance_criteria> <acceptance_criteria>
- `! grep -R 'Click me!' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` - `grep -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt`
- `grep -q 'collectAsState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` - `! grep -R 'NSUserDefaults\\|UserDefaults' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth`
- `grep -q 'SplashScreen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` - `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist`
- `grep -q 'auth_welcome_format' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt` - `grep -q 'recipe' iosApp/iosApp/Info.plist`
- `! grep -R 'Zaloguj\\|Wyloguj\\|Witaj\\|Nie można\\|Coś poszło' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe --include='*.kt'` - `grep -q 'onOpenURL\\|application(.*open' iosApp/iosApp/iOSApp.swift`
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0 - `./gradlew :composeApp:compileKotlinIosSimulatorArm64` exits 0
</acceptance_criteria> </acceptance_criteria>
<done>Auth gate UI compiles, uses resources, and has no dangling reference to a missing plan.</done> <done>iOS token storage is explicit and the custom URL callback is wired back into AppAuth.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Manual iOS Authentik UAT</name>
<read_first>
- docs/authentik-setup.md
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (Manual-Only Verifications)
</read_first>
<files>docs/authentik-setup.md, .planning/phases/02-authentication-foundation/02-05-SUMMARY.md</files>
<action>
Run automated gate first: `./gradlew check`.
Then perform the manual UAT from `docs/authentik-setup.md` on iOS simulator/device with the real Authentik provider:
1. Fresh install opens Splash then LoginScreen.
2. Tap `Zaloguj się przez Authentik`; hosted Authentik login opens and returns through `recipe://callback`.
3. App shows `Witaj, {displayName}!`.
4. Restart after access-token expiry or shortened token lifetime; app returns to authenticated screen without credentials.
5. Tap `Wyloguj się`; app returns to LoginScreen; restart does not silently authenticate.
6. `GET /api/v1/me` returns 200 with valid token and 401 without token or with wrong-audience token.
</action>
<verify>
<automated>./gradlew check</automated>
</verify>
<acceptance_criteria>
- `./gradlew check` exits 0
- Manual UAT result recorded in `.planning/phases/02-authentication-foundation/02-05-SUMMARY.md`
- If any UAT step fails, record exact step, observed behavior, logs with tokens redacted, and do not mark Phase 2 complete
</acceptance_criteria>
<what-built>Phase 2 end-to-end auth flow: Authentik login, secure token persistence, server /me, and logout UI.</what-built>
<how-to-verify>Follow the six UAT steps in the action block using the real Authentik provider configured from docs/authentik-setup.md.</how-to-verify>
<resume-signal>Type "approved" if UAT passes, or describe the failing step and observed behavior.</resume-signal>
<done>Automated tests are green and the user confirms fresh login, persisted session refresh, logout, and /api/v1/me behavior.</done>
</task> </task>
</tasks> </tasks>
@@ -174,27 +142,26 @@ Output: auth screens, auth gate, resource strings, UI ViewModels, and validation
| Boundary | Description | | Boundary | Description |
|----------|-------------| |----------|-------------|
| UI -> AuthSession | User taps login/logout and triggers token-bearing flows | | system browser -> iOS app | Authorization code returns through custom URL scheme |
| AuthSession -> UI | Auth errors are mapped to user-visible strings | | iOS app -> Keychain | AuthState JSON containing refresh token is persisted |
| Human UAT -> logs | Manual validation may inspect logs while tokens exist | | Swift shell -> KMP auth bridge | openURL callback crosses from SwiftUI into KMP/AppAuth flow state |
## STRIDE Threat Register ## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan | | Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------| |-----------|----------|-----------|-------------|-----------------|
| T-02-05-01 | Information Disclosure | UI/log validation | mitigate | UAT summary must redact tokens and Authorization headers | | 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 | logout UX | mitigate | Logout button delegates to AuthSession.logout; UAT verifies no silent restore after relaunch | | T-02-05-02 | Information Disclosure | iOS token store | mitigate | Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`; grep forbids UserDefaults in auth |
| T-02-05-03 | Spoofing | login UX | mitigate | Button explicitly opens Authentik; AppAuth handles browser flow and callback | | 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 | Denial of Service | refresh UX | mitigate | Reopen-after-expiry UAT verifies transparent refresh path | | T-02-05-04 | Spoofing | Swift callback bridge | mitigate | `onOpenURL` forwards only registered callback URLs to the active AppAuth session |
| T-02-05-05 | Tampering | raw strings | mitigate | All auth copy comes from Compose Resources, preventing ad hoc UI string drift |
</threat_model> </threat_model>
<verification> <verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`, then `./gradlew check`, then complete iOS Authentik UAT. Run `./gradlew :composeApp:compileKotlinIosSimulatorArm64`.
</verification> </verification>
<success_criteria> <success_criteria>
The app visibly satisfies Phase 2 roadmap criteria: sign in, stay signed in, sign out, and prove server `/api/v1/me` works with valid/invalid tokens. iOS AppAuth login/refresh/logout, iOS Keychain AuthState persistence, URL scheme registration, and callback forwarding compile independently below the file-count threshold.
</success_criteria> </success_criteria>
<output> <output>

View File

@@ -0,0 +1,196 @@
---
phase: 02-authentication-foundation
plan: 06
type: execute
wave: 4
depends_on: [02-01, 02-02, 02-03, 02-04, 02-05]
files_modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves:
truths:
- "AuthSession starts in Loading and restores persisted AuthState JSON before deciding Authenticated or Unauthenticated"
- "Authenticated state contains User and householdId = null in Phase 2"
- "Authenticated API calls get fresh access tokens proactively and Ktor bearer auth can reactively refresh on 401"
- "Refresh invalid_grant transitions silently to Unauthenticated"
- "logout() attempts RP end-session and clears local AuthState even if end-session fails"
- "AuthSession is a Koin singleton in authModule and wired into appModule"
artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt"
provides: "Loading/Unauthenticated/Authenticated(user, householdId?) state model per D-28"
contains: "householdId"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
provides: "StateFlow auth owner per D-29"
exports: ["AuthSession"]
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt"
provides: "Ktor client bearer auth with refreshTokens per D-17"
contains: "refreshTokens"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt"
provides: "GET /api/v1/me client returning MeResponse"
key_links:
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt"
via: "login/refresh/logout delegate to platform AppAuth seam"
pattern: "oidcClient"
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt"
via: "after login/restore, fetch /api/v1/me to build Authenticated(user, null)"
pattern: "meClient"
---
<objective>
Build the common client auth runtime: `AuthSession`, authenticated Ktor client, `/api/v1/me` client, and Koin wiring.
Purpose: compose common contracts from Plan 03, Android/iOS OIDC/storage from Plans 04/05, and server `/api/v1/me` from Plan 02 into persistent app session behavior.
Output: tested common auth state machine and DI module.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<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
@.planning/phases/02-authentication-foundation/02-02-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-04-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-05-SUMMARY.md
@AGENTS.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Write AuthSession state-machine tests</name>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (AuthSessionTest)
</read_first>
<files>composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt</files>
<behavior>
- Empty store initializes `Loading -> Unauthenticated`.
- Successful login writes AuthState JSON, calls `/api/v1/me`, and emits `Authenticated(user, householdId = null)`.
- Existing store refreshes before `/api/v1/me` and emits Authenticated without login.
- Refresh `invalid_grant` or AuthError clears store and emits Unauthenticated without UI error.
- Logout calls `OidcClient.logout(authStateJson)` then clears store and emits Unauthenticated even when logout throws.
- Login cancelled maps to a result the UI can render as cancelled.
</behavior>
<action>
Create fakes for `OidcClient`, `SecureAuthStateStore`, and `MeClient`. Write tests for the exact behaviors above before production implementation. Keep tests in commonTest and avoid platform AppAuth classes.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'invalid_grant\\|AuthError' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
- `grep -q 'householdId' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
- After Task 2, `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0
</acceptance_criteria>
<done>State-machine tests cover AUTH-04/AUTH-05 and validation Wave 0 requirements.</done>
</task>
<task type="auto">
<name>Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client</name>
<read_first>
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-16, D-17, D-18, D-28, D-29)
</read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt</files>
<action>
Create sealed `AuthState` with `Loading`, `Unauthenticated`, `Authenticated(user: User, householdId: HouseholdId? = null)` and `typealias HouseholdId = String`.
Implement `MeClient.getMe(accessToken: String? = null)` calling `GET ${Constants.API_BASE_URL}/api/v1/me`, decoding `MeResponse`, and mapping to `User`. If `accessToken` is supplied for tests/simple calls, attach `Authorization: Bearer <token>` without logging it.
Implement `AuthHttpClient.create(authSession)` using Ktor Client `Auth { bearer { loadTokens { ... }; refreshTokens { ... }; sendWithoutRequest { request.url.host == Url(Constants.API_BASE_URL).host } } }`, ContentNegotiation JSON, and logging that redacts token-bearing headers.
Implement `AuthSession` with `state: StateFlow<AuthState>`, `initialize()`, `login()`, `logout()`, `getAccessToken()`, `currentBearerTokens()`, and `refreshBearerTokens()`. `initialize()` reads stored AuthState JSON, refreshes with AppAuth, persists updated JSON, calls `/api/v1/me`, and emits `Authenticated(user, null)`. On refresh failure, clear the store and emit Unauthenticated silently. On logout, call `OidcClient.logout(storedJson)` first, then always clear.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'StateFlow<AuthState>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt`
- `grep -q 'refreshTokens' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt`
- `grep -q 'sendWithoutRequest' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt`
- `! grep -R 'Authorization.*\\$' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth`
- `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0
</acceptance_criteria>
<done>Common auth runtime passes the state-machine tests and supports transparent refresh.</done>
</task>
<task type="auto">
<name>Task 3: Wire authModule into Koin</name>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-29)
</read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt</files>
<action>
Create `authModule = module { ... }` providing singleton `SecureAuthStateStore`, `OidcClient`, `MeClient`, `AuthSession`, and auth-related ViewModels only if their classes already exist. Wire `appModule` to include auth definitions without starting Koin from composables. If target-specific constructors need Android context/activity, use Koin platform APIs already available in Phase 1 Android bootstrap.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `grep -q 'val authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt`
- `grep -q 'authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0
</acceptance_criteria>
<done>AuthSession and collaborators are available as Koin singletons for the UI gate.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| AuthSession -> server | Access token attached to `/api/v1/me` |
| AuthSession -> secure store | Refresh-capable AuthState JSON persists across app launches |
| AuthSession -> UI | Auth failures influence rendered state and messages |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-06-01 | Information Disclosure | AuthHttpClient logging | mitigate | Redact Authorization and never log token values |
| T-02-06-02 | Information Disclosure | AuthSession logout | mitigate | Always clear stored AuthState after logout attempt, including end-session failure |
| T-02-06-03 | Denial of Service | refresh path | mitigate | Proactive refresh before calls and Ktor bearer reactive refresh on 401 |
| T-02-06-04 | Spoofing | `/api/v1/me` response | mitigate | Authenticated state is built only from server `MeResponse`, not client-decoded token claims |
| T-02-06-05 | Repudiation | silent invalid_grant | accept | Silent return to login is a locked UX decision D-18; auth warnings may log without secrets |
</threat_model>
<verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`.
</verification>
<success_criteria>
AUTH-04 and AUTH-05 work at the session layer: persisted tokens restore, refresh failures clear state, and logout wipes recoverable refresh tokens.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-06-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,202 @@
---
phase: 02-authentication-foundation
plan: 07
type: execute
wave: 5
depends_on: [02-06]
files_modified:
- composeApp/src/commonMain/composeResources/values/strings.xml
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt
autonomous: false
requirements: [AUTH-01, AUTH-04, AUTH-05]
must_haves:
truths:
- "Fresh launch in Loading shows SplashScreen with Recipe wordmark and progress indicator"
- "Unauthenticated state shows LoginScreen with Polish Authentik sign-in button"
- "Login errors render inline below the button and retry clears stale error"
- "Authenticated state shows Witaj, {displayName}! and Wyloguj się"
- "Wyloguj się returns to LoginScreen through AuthSession.logout()"
- "All Phase 2 user-facing strings come from Compose Resources"
artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt"
provides: "Auth gate rendering Splash/Login/PostLogin by AuthState"
contains: "when"
- path: "composeApp/src/commonMain/composeResources/values/strings.xml"
provides: "auth_app_name/auth_sign_in_button/auth_sign_out_button/auth_welcome_format/auth_error_*"
contains: "auth_sign_in_button"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt"
provides: "UI-SPEC login layout and inline error state"
contains: "auth_sign_in_button"
key_links:
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
via: "collectAsState over AuthSession.state"
pattern: "collectAsState"
- from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt"
to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
via: "onSignOutClick delegates to logout"
pattern: "logout"
---
<objective>
Deliver the user-facing Phase 2 auth experience and final validation gate.
Purpose: make end-to-end auth observable: login button, loading screen, welcome confirmation, logout button, and manual iOS Authentik UAT.
Output: auth screens, auth gate, resource strings, UI ViewModels, and validation checklist execution.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/ROADMAP.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-UI-SPEC.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-06-SUMMARY.md
@AGENTS.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add Compose Resources, theme seed, and ViewModel tests</name>
<read_first>
- .planning/phases/02-authentication-foundation/02-UI-SPEC.md
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
</read_first>
<files>composeApp/src/commonMain/composeResources/values/strings.xml, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt</files>
<behavior>
- String keys exist with exact Polish scaffold copy from UI-SPEC.
- `RecipeTheme` uses Material 3 light/dark schemes with primary seed `#3B6939` / dark variant `#A2D597`.
- LoginViewModel maps cancelled/network/unknown auth failures to the correct string resource keys.
- Starting a new login clears previous inline error and sets loading.
</behavior>
<action>
Create `strings.xml` keys: `auth_app_name`, `auth_sign_in_button`, `auth_sign_out_button`, `auth_welcome_format`, `auth_error_cancelled`, `auth_error_network`, `auth_error_unknown` with exact UI-SPEC copy.
Add `RecipeTheme(content)` with `lightColorScheme(primary = Color(0xFF3B6939))`, `darkColorScheme(primary = Color(0xFFA2D597))`, `isSystemInDarkTheme()`, and Material 3 typography defaults. Do not add Haze, blur, images, icons, Scaffold, or marketing copy.
Write `LoginViewModelTest` against a fake `AuthSession` result interface before implementing the ViewModel in Task 2.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'name="auth_sign_in_button">Zaloguj się przez Authentik' composeApp/src/commonMain/composeResources/values/strings.xml`
- `grep -q '0xFF3B6939' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt`
- `grep -q 'auth_error_cancelled' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt`
- After Task 2, `./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"` exits 0
</acceptance_criteria>
<done>Resource and theme foundations match UI-SPEC and login error mapping is tested.</done>
</task>
<task type="auto">
<name>Task 2: Implement auth screens, ViewModels, and App auth gate</name>
<read_first>
- .planning/phases/02-authentication-foundation/02-UI-SPEC.md (Component Inventory, Layout Contract, Auth Gate Routing Contract)
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
</read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt</files>
<action>
Replace template `App()` body with `RecipeTheme { val authState by authSession.state.collectAsState(); when(authState) { Loading -> SplashScreen(); Unauthenticated -> LoginScreen(koinViewModel()); Authenticated -> PostLoginPlaceholderScreen(user, koinViewModel()) } }`. State changes drive recomposition; no manual navigation or Scaffold.
Implement `SplashScreen`, `LoginScreen`, and `PostLoginPlaceholderScreen` exactly from UI-SPEC: centered column, `safeContentPadding`, horizontal 16.dp, displaySmall wordmark, Login button with loading indicator, inline bodyLarge error text below button, welcome `headlineSmall`, logout `OutlinedButton`. All strings must use `stringResource(Res.string.*)`.
Implement `LoginViewModel` with method `onSignInClick()` and immutable `LoginScreenState(isLoading: Boolean, errorKey: StringResource?)`. Implement `PostLoginViewModel.onSignOutClick()` delegating to `AuthSession.logout()`. Register ViewModels in `authModule` using existing Koin Compose ViewModel pattern.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `! grep -R 'Click me!' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `grep -q 'collectAsState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `grep -q 'SplashScreen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `grep -q 'auth_welcome_format' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt`
- `! grep -R 'Zaloguj\\|Wyloguj\\|Witaj\\|Nie można\\|Coś poszło' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe --include='*.kt'`
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0
</acceptance_criteria>
<done>Auth gate UI compiles, uses resources, and has no dangling reference to a missing plan.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Manual iOS Authentik UAT</name>
<read_first>
- docs/authentik-setup.md
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (Manual-Only Verifications)
</read_first>
<files>docs/authentik-setup.md, .planning/phases/02-authentication-foundation/02-07-SUMMARY.md</files>
<action>
Run automated gate first: `./gradlew check`.
Then perform the manual UAT from `docs/authentik-setup.md` on iOS simulator/device with the real Authentik provider:
1. Fresh install opens Splash then LoginScreen.
2. Tap `Zaloguj się przez Authentik`; hosted Authentik login opens and returns through `recipe://callback`.
3. App shows `Witaj, {displayName}!`.
4. Restart after access-token expiry or shortened token lifetime; app returns to authenticated screen without credentials.
5. Tap `Wyloguj się`; app returns to LoginScreen; restart does not silently authenticate.
6. `GET /api/v1/me` returns 200 with valid token and 401 without token or with wrong-audience token.
</action>
<verify>
<automated>./gradlew check</automated>
</verify>
<acceptance_criteria>
- `./gradlew check` exits 0
- Manual UAT result recorded in `.planning/phases/02-authentication-foundation/02-07-SUMMARY.md`
- If any UAT step fails, record exact step, observed behavior, logs with tokens redacted, and do not mark Phase 2 complete
</acceptance_criteria>
<what-built>Phase 2 end-to-end auth flow: Authentik login, secure token persistence, server /me, and logout UI.</what-built>
<how-to-verify>Follow the six UAT steps in the action block using the real Authentik provider configured from docs/authentik-setup.md.</how-to-verify>
<resume-signal>Type "approved" if UAT passes, or describe the failing step and observed behavior.</resume-signal>
<done>Automated tests are green and the user confirms fresh login, persisted session refresh, logout, and /api/v1/me behavior.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| UI -> AuthSession | User taps login/logout and triggers token-bearing flows |
| AuthSession -> UI | Auth errors are mapped to user-visible strings |
| Human UAT -> logs | Manual validation may inspect logs while tokens exist |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-07-01 | Information Disclosure | UI/log validation | mitigate | UAT summary must redact tokens and Authorization headers |
| T-02-07-02 | Information Disclosure | logout UX | mitigate | Logout button delegates to AuthSession.logout; UAT verifies no silent restore after relaunch |
| T-02-07-03 | Spoofing | login UX | mitigate | Button explicitly opens Authentik; AppAuth handles browser flow and callback |
| T-02-07-04 | Denial of Service | refresh UX | mitigate | Reopen-after-expiry UAT verifies transparent refresh path |
| T-02-07-05 | Tampering | raw strings | mitigate | All auth copy comes from Compose Resources, preventing ad hoc UI string drift |
</threat_model>
<verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`, then `./gradlew check`, then complete iOS Authentik UAT.
</verification>
<success_criteria>
The app visibly satisfies Phase 2 roadmap criteria: sign in, stay signed in, sign out, and prove server `/api/v1/me` works with valid/invalid tokens.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-07-SUMMARY.md`.
</output>