diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index db90a47..3a83155 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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. 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). -**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 **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. 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. -**Plans:** 5 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 — 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 +**Plans:** TBD **UI hint:** yes **Research flag:** no @@ -220,7 +222,7 @@ Plans: | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 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 | - | | 4. Sync Engine Skeleton | 0/0 | Not started | - | | 5. Recipe Catalog (Read Path) | 0/0 | Not started | - | diff --git a/.planning/phases/02-authentication-foundation/02-03-PLAN.md b/.planning/phases/02-authentication-foundation/02-03-PLAN.md index fad34c4..4d1e836 100644 --- a/.planning/phases/02-authentication-foundation/02-03-PLAN.md +++ b/.planning/phases/02-authentication-foundation/02-03-PLAN.md @@ -8,14 +8,6 @@ files_modified: - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt - - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt - - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt - - composeApp/src/androidMain/AndroidManifest.xml - - composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt - - composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt - - iosApp/iosApp/Info.plist - - iosApp/iosApp/iOSApp.swift - - iosApp/Podfile - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.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] must_haves: truths: - - "iOS and Android login use AppAuth authorization-code flow with PKCE through system browser and recipe://callback" - - "Requested scopes are exactly openid profile email offline_access" - - "AuthState JSON is stored through explicit iOS Keychain and Android EncryptedSharedPreferences-backed stores" - - "Every configured KMP target has a SecureAuthStateStore actual, including JVM and Wasm stubs" - - "JVM target has DEV_AUTH_TOKEN dev stub; Wasm target throws NotImplementedError(\"Wasm OIDC: v2\")" - - "Logout platform clients support RP-initiated end-session and local store clearing" + - "Common auth code compiles against one expect OidcClient seam with login, refresh, and logout" + - "Requested native OIDC scopes are documented in the common contract as exactly openid profile email offline_access" + - "Every configured non-mobile target has actuals so JVM and Wasm builds compile" + - "JVM target uses explicit DEV_AUTH_TOKEN dev behavior and does not hardcode a usable bearer token" + - "Wasm target preserves the v2 boundary with NotImplementedError(\"Wasm OIDC: v2\")" + - "SecureAuthStateStore read/write/clear semantics are locked by a common contract test" artifacts: - 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" contains: "expect class OidcClient" - - path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt" - provides: "Android explicit secure token storage per AUTH-02" - contains: "EncryptedSharedPreferences" - - path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt" - provides: "iOS Keychain storage with AfterFirstUnlockThisDeviceOnly per D-14" - contains: "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly" - - path: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt" - provides: "JVM dev-only in-memory AuthState store actual so desktop tests compile" - contains: "actual class SecureAuthStateStore" - - path: "composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt" - provides: "Wasm non-persistent AuthState store actual so wasm target compiles while OIDC remains v2" - contains: "actual class SecureAuthStateStore" - - path: "iosApp/iosApp/Info.plist" - provides: "recipe URL scheme registration" - contains: "CFBundleURLSchemes" + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt" + provides: "common OIDC result model consumed by AuthSession and LoginViewModel" + contains: "sealed" + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt" + provides: "expect secure AuthState JSON store per D-13..D-15" + contains: "expect class SecureAuthStateStore" + - path: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt" + provides: "JVM dev-only token stub per D-02" + contains: "DEV_AUTH_TOKEN" + - path: "composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt" + provides: "Wasm v2 stub per D-03" + contains: "NotImplementedError(\"Wasm OIDC: v2\")" key_links: - - from: "composeApp/src/androidMain/AndroidManifest.xml" - to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt" - via: "AppAuth redirect receiver for recipe://callback" - pattern: "RedirectUriReceiverActivity|recipe" - - from: "iosApp/iosApp/iOSApp.swift" - to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt" - via: "openURL forwards callback to current AppAuth external user-agent session" - pattern: "onOpenURL|currentAuthorizationFlow" + - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt" + to: "composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt" + via: "actual class implements common suspend login/refresh/logout contract" + pattern: "actual class OidcClient" + - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt" + to: "composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt" + via: "contract test validates read/write/clear behavior without platform secure storage" + pattern: "read.*write.*clear" --- -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. -Output: expect/actual OIDC client, explicit secure auth state store, URL callback registration, and platform stubs. +Purpose: downstream mobile plans implement platform AppAuth behind a stable seam, while AuthSession can be built without platform-specific APIs. +Output: common auth contracts, JVM dev actuals, Wasm v2 stubs, and a common store contract test. @@ -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-01-SUMMARY.md @AGENTS.md -@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt -@iosApp/iosApp/iOSApp.swift +@composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt +@composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt - - Task 1: Define OidcClient and secure store common contracts + + Task 1: Define common OIDC and secure store contracts - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01..D-06, D-13..D-20) - - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 1) + - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 1 and secure storage recommendation) 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 + + - `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. + - 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. ./gradlew :composeApp:jvmTest @@ -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 '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 '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 - Common auth platform seams exist with testable store semantics and exact scope contract. + Common auth seams exist with exact scope/logout/storage semantics and testable store behavior. - Task 2: Implement Android and iOS AppAuth actuals with explicit secure storage - - - composeApp/build.gradle.kts - - composeApp/src/androidMain/AndroidManifest.xml - - iosApp/iosApp/Info.plist - - iosApp/iosApp/iOSApp.swift - - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-09, D-13, D-14, D-19, D-20) - - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt, composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt, composeApp/src/androidMain/AndroidManifest.xml, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift, iosApp/Podfile - - Android: implement AppAuth-Android using `AuthorizationServiceConfiguration.fetchFromIssuer`, `AuthorizationRequest.Builder`, `ResponseTypeValues.CODE`, `setScopes("openid", "profile", "email", "offline_access")`, AppAuth PKCE defaults, `suspendCancellableCoroutine`, `AuthState.jsonSerializeString()`, `AuthState.jsonDeserialize(...)`, `performActionWithFreshTokens`, and `EndSessionRequest` when metadata exposes end-session. Register `net.openid.appauth.RedirectUriReceiverActivity` for scheme `recipe` host `callback`. - - Android secure storage decision: use AndroidX Security Crypto `EncryptedSharedPreferences` behind `SecureAuthStateStore.android.kt` for AUTH-02 because the requirement explicitly calls out Android EncryptedSharedPreferences. Document in code comment that the dependency is deprecated upstream but isolated behind `SecureAuthStateStore`; do not use no-arg `Settings()` or ordinary `SharedPreferences` for auth tokens. - - iOS: implement AppAuth-iOS via CocoaPods/interop using `OIDAuthorizationService`, `OIDAuthState`, `OIDAuthorizationRequest`, `OIDTokenRequest` refresh/fresh-token helpers, `OIDEndSessionRequest`, and `suspendCancellableCoroutine`. Register `CFBundleURLTypes` for `recipe`. Add SwiftUI `.onOpenURL` or app delegate bridge in `iOSApp.swift` to resume the current AppAuth flow. - - iOS secure storage: implement Keychain read/write/delete with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. Persist the full AppAuth AuthState JSON blob per D-13. - - - ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 - - - - `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt` - - `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth` - - `grep -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt` - - `grep -q 'offline_access' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - - `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` - - `grep -q 'recipe' composeApp/src/androidMain/AndroidManifest.xml` - - `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist` - - `./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0 - - Mobile targets compile with AppAuth login/refresh/logout and explicit secure AuthState persistence. - - - - Task 3: Add JVM and Wasm target actuals + Task 2: Add JVM and Wasm actuals - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-02, D-03) - 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 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 - 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 `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. + 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. ./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs @@ -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` - `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0 - Secondary targets compile without expanding Phase 2 scope into real Desktop/Wasm OIDC. + Secondary targets compile without expanding Phase 2 into real Desktop or Wasm OIDC. @@ -187,27 +150,26 @@ Output: expect/actual OIDC client, explicit secure auth state store, URL callbac | Boundary | Description | |----------|-------------| -| system browser -> app | Authorization code returns through custom URL scheme | -| app process -> OS secure storage | AuthState JSON containing refresh token is persisted | -| app -> Authentik | Refresh and end-session requests exchange tokens with IdP | +| common auth contract -> platform actuals | Common AuthSession code delegates browser/token behavior to target-specific implementations | +| app process -> dev environment | JVM dev stub reads bearer token from `DEV_AUTH_TOKEN` | +| app process -> non-persistent stubs | JVM/Wasm stores satisfy contracts without claiming production secure storage | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| -| T-02-03-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; redirect URI byte-matched to `recipe://callback` | -| T-02-03-02 | Information Disclosure | Android token store | mitigate | Use EncryptedSharedPreferences behind `SecureAuthStateStore`; grep forbids no-arg `Settings()` in auth | -| T-02-03-03 | Information Disclosure | iOS token store | mitigate | Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | -| T-02-03-04 | Information Disclosure | AppAuth diagnostics | mitigate | Do not log AuthState JSON, access tokens, refresh tokens, id tokens, or Authorization headers | -| T-02-03-05 | Spoofing | JVM dev stub | accept | Desktop is dev tool only; stub requires explicit `DEV_AUTH_TOKEN` and is not a release surface | +| 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 | 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 | 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 | Scope Creep | Wasm OIDC | accept | Wasm actual throws `NotImplementedError("Wasm OIDC: v2")` per D-03 and does not implement browser OIDC in Phase 2 | -Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileKotlinWasmJs`. +Run `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs`. -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. diff --git a/.planning/phases/02-authentication-foundation/02-04-PLAN.md b/.planning/phases/02-authentication-foundation/02-04-PLAN.md index 7eabb23..1004eb5 100644 --- a/.planning/phases/02-authentication-foundation/02-04-PLAN.md +++ b/.planning/phases/02-authentication-foundation/02-04-PLAN.md @@ -3,53 +3,46 @@ phase: 02-authentication-foundation plan: 04 type: execute wave: 3 -depends_on: [02-02, 02-03] +depends_on: [02-01, 02-03] 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 + - 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 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" + - "Android login uses AppAuth authorization-code flow with PKCE through system browser and recipe://callback" + - "Android requested scopes are exactly openid profile email offline_access" + - "Android persists full AppAuth AuthState JSON through EncryptedSharedPreferences-backed SecureAuthStateStore" + - "Android refresh uses AppAuth fresh-token behavior and persists updated AuthState JSON" + - "Android logout uses AppAuth end-session when metadata exposes an endpoint" 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" + - path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt" + provides: "Android AppAuth actual per D-01, D-04, D-16, D-19, D-20" + contains: "AuthorizationService" + - path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt" + provides: "Android explicit secure token storage per AUTH-02" + contains: "EncryptedSharedPreferences" + - path: "composeApp/src/androidMain/AndroidManifest.xml" + provides: "recipe://callback registration for AppAuth redirect receiver" + contains: "RedirectUriReceiverActivity" 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" + - from: "composeApp/src/androidMain/AndroidManifest.xml" + to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt" + via: "AppAuth redirect receiver for recipe://callback" + pattern: "RedirectUriReceiverActivity|recipe" + - from: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt" + to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt" + via: "AuthState JSON returned by AppAuth is what AuthSession persists through the store" + pattern: "jsonSerializeString|jsonDeserialize" --- -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. -Output: tested common auth state machine and DI module. +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: Android AppAuth OidcClient actual, Android secure AuthState store, and Android callback manifest registration. @@ -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-VALIDATION.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 @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 - - Task 1: Write AuthSession state-machine tests + + Task 1: Implement Android AppAuth OidcClient actual - 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) - - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt - - - 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. - - - 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. - - - ./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*" - - - - `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 - - State-machine tests cover AUTH-04/AUTH-05 and validation Wave 0 requirements. - - - - Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client - - - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.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) + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-05, D-06, D-09, D-16, D-19, D-20) - composeApp/src/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/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt - 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 ` 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. - - Implement `AuthSession` with `state: StateFlow`, `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. + 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. - ./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*" + ./gradlew :composeApp:compileDebugKotlinAndroid - - `grep -q 'StateFlow' 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 + - `grep -q 'AuthorizationServiceConfiguration.fetchFromIssuer' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` + - `grep -q 'setScopes("openid", "profile", "email", "offline_access")' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` + - `grep -q 'suspendCancellableCoroutine' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` + - `grep -q 'performActionWithFreshTokens' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` + - `grep -q 'EndSessionRequest' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` + - `./gradlew :composeApp:compileDebugKotlinAndroid` exits 0 - Common auth runtime passes the state-machine tests and supports transparent refresh. + Android AppAuth login, refresh, and logout compile behind the common OidcClient contract. - Task 3: Wire authModule into Koin + Task 2: Implement Android secure AuthState store and callback manifest - - 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) + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt + - composeApp/src/androidMain/AndroidManifest.xml + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-09, D-13, D-15) + - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Android secure storage correction) - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt + composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt, composeApp/src/androidMain/AndroidManifest.xml - 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`). - ./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 + ./gradlew :composeApp:compileDebugKotlinAndroid - - `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 + - `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 -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 - AuthSession and collaborators are available as Koin singletons for the UI gate. + Android token storage is explicit and the custom URL callback is registered for AppAuth. @@ -165,27 +134,26 @@ Output: tested common auth state machine and DI module. | 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 | +| system browser -> Android app | Authorization code returns through custom URL scheme | +| Android app -> OS secure storage | AuthState JSON containing refresh token is persisted | +| Android app -> Authentik | Refresh and end-session requests exchange tokens with IdP | ## STRIDE Threat Register | 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-02 | Information Disclosure | AuthSession logout | mitigate | Always clear stored AuthState after logout attempt, including end-session failure | -| T-02-04-03 | Denial of Service | refresh path | mitigate | Proactive refresh before calls and Ktor bearer reactive refresh on 401 | -| 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-05 | Repudiation | silent invalid_grant | accept | Silent return to login is a locked UX decision D-18; auth warnings may log without secrets | +| 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 | Android token store | mitigate | Use EncryptedSharedPreferences behind `SecureAuthStateStore`; grep forbids no-arg `Settings()` and plaintext SharedPreferences in auth | +| 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 | Denial of Service | refresh path | mitigate | Use AppAuth `performActionWithFreshTokens` so expiry refresh is handled before authenticated calls | -Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`. +Run `./gradlew :composeApp:compileDebugKotlinAndroid`. -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. diff --git a/.planning/phases/02-authentication-foundation/02-05-PLAN.md b/.planning/phases/02-authentication-foundation/02-05-PLAN.md index 0b8a56b..54bed0d 100644 --- a/.planning/phases/02-authentication-foundation/02-05-PLAN.md +++ b/.planning/phases/02-authentication-foundation/02-05-PLAN.md @@ -2,54 +2,55 @@ phase: 02-authentication-foundation plan: 05 type: execute -wave: 4 -depends_on: [02-04] +wave: 3 +depends_on: [02-01, 02-03] 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] + - 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 +autonomous: true +requirements: [AUTH-01, AUTH-02, 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" + - "iOS login uses AppAuth authorization-code flow with PKCE through system browser and recipe://callback" + - "iOS requested scopes are exactly openid profile email offline_access" + - "iOS persists full AppAuth AuthState JSON in Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly" + - "SwiftUI callback wiring forwards recipe://callback to the current AppAuth flow" + - "iOS logout uses AppAuth end-session when metadata exposes an endpoint" 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" + - path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt" + provides: "iOS AppAuth actual per D-01, D-04, D-16, D-19, D-20" + contains: "OIDAuthorizationService" + - path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt" + provides: "iOS Keychain storage per D-14" + contains: "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly" + - path: "iosApp/iosApp/Info.plist" + provides: "recipe URL scheme registration" + 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: - - 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" + - from: "iosApp/iosApp/iOSApp.swift" + to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt" + via: "openURL forwards callback to current AppAuth external user-agent session" + pattern: "onOpenURL|currentAuthorizationFlow" + - from: "iosApp/iosApp/Info.plist" + to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt" + via: "registered URL scheme matches redirect URI consumed by AppAuth" + pattern: "CFBundleURLSchemes|recipe" --- -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. -Output: auth screens, auth gate, resource strings, UI ViewModels, and validation checklist execution. +Purpose: satisfy iOS-primary AUTH-01/AUTH-02/AUTH-04/AUTH-05 behind the common contracts from Plan 02-03 without mixing Android work into the same execution plan. +Output: iOS AppAuth OidcClient actual, iOS Keychain AuthState store, URL scheme registration, Swift callback wiring, and Podfile integration. @@ -60,111 +61,78 @@ Output: auth screens, auth gate, resource strings, UI ViewModels, and validation @.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-RESEARCH.md @.planning/phases/02-authentication-foundation/02-VALIDATION.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 -@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt +@iosApp/iosApp/Info.plist +@iosApp/iosApp/iOSApp.swift - - Task 1: Add Compose Resources, theme seed, and ViewModel tests + + Task 1: Implement iOS AppAuth OidcClient actual and CocoaPods bridge - - .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 + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt + - iosApp/Podfile + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-05, D-06, D-09, D-16, D-19, D-20) - composeApp/src/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 - - - 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. - + composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, iosApp/Podfile - 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. - ./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*" + ./gradlew :composeApp:compileKotlinIosSimulatorArm64 - - `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 + - `grep -q 'OIDAuthorizationService' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` + - `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` + - `grep -q 'suspendCancellableCoroutine' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` + - `grep -q 'OIDEndSessionRequest' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` + - `grep -q 'AppAuth' iosApp/Podfile composeApp/build.gradle.kts` + - `./gradlew :composeApp:compileKotlinIosSimulatorArm64` exits 0 - Resource and theme foundations match UI-SPEC and login error mapping is tested. + iOS AppAuth login, refresh, and logout compile behind the common OidcClient contract. - Task 2: Implement auth screens, ViewModels, and App auth gate + Task 2: Implement iOS Keychain store and callback wiring - - .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 + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt + - iosApp/iosApp/Info.plist + - iosApp/iosApp/iOSApp.swift + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-09, D-13, D-14, D-15) - composeApp/src/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 + composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift - 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. - ./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 + ./gradlew :composeApp:compileKotlinIosSimulatorArm64 - - `! 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 + - `grep -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt` + - `! grep -R 'NSUserDefaults\\|UserDefaults' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth` + - `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist` + - `grep -q 'recipe' iosApp/iosApp/Info.plist` + - `grep -q 'onOpenURL\\|application(.*open' iosApp/iosApp/iOSApp.swift` + - `./gradlew :composeApp:compileKotlinIosSimulatorArm64` exits 0 - Auth gate UI compiles, uses resources, and has no dangling reference to a missing plan. - - - - Task 3: Manual iOS Authentik UAT - - - docs/authentik-setup.md - - .planning/phases/02-authentication-foundation/02-VALIDATION.md (Manual-Only Verifications) - - docs/authentik-setup.md, .planning/phases/02-authentication-foundation/02-05-SUMMARY.md - - 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. - - - ./gradlew check - - - - `./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 - - Phase 2 end-to-end auth flow: Authentik login, secure token persistence, server /me, and logout UI. - Follow the six UAT steps in the action block using the real Authentik provider configured from docs/authentik-setup.md. - Type "approved" if UAT passes, or describe the failing step and observed behavior. - Automated tests are green and the user confirms fresh login, persisted session refresh, logout, and /api/v1/me behavior. + iOS token storage is explicit and the custom URL callback is wired back into AppAuth. @@ -174,27 +142,26 @@ Output: auth screens, auth gate, resource strings, UI ViewModels, and validation | 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 | +| system browser -> iOS app | Authorization code returns through custom URL scheme | +| iOS app -> Keychain | AuthState JSON containing refresh token is persisted | +| Swift shell -> KMP auth bridge | openURL callback crosses from SwiftUI into KMP/AppAuth flow state | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| -| T-02-05-01 | Information Disclosure | UI/log validation | mitigate | UAT summary must redact tokens and Authorization headers | -| 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-03 | Spoofing | login UX | mitigate | Button explicitly opens Authentik; AppAuth handles browser flow and callback | -| T-02-05-04 | Denial of Service | refresh UX | mitigate | Reopen-after-expiry UAT verifies transparent refresh path | -| T-02-05-05 | Tampering | raw strings | mitigate | All auth copy comes from Compose Resources, preventing ad hoc UI string drift | +| T-02-05-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; Info.plist byte-matches `recipe://callback` | +| T-02-05-02 | Information Disclosure | iOS token store | mitigate | Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`; grep forbids UserDefaults in auth | +| T-02-05-03 | Information Disclosure | AppAuth diagnostics | mitigate | iOS actual must not log AuthState JSON, access tokens, refresh tokens, ID tokens, or Authorization headers | +| T-02-05-04 | Spoofing | Swift callback bridge | mitigate | `onOpenURL` forwards only registered callback URLs to the active AppAuth session | -Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`, then `./gradlew check`, then complete iOS Authentik UAT. +Run `./gradlew :composeApp:compileKotlinIosSimulatorArm64`. -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. diff --git a/.planning/phases/02-authentication-foundation/02-06-PLAN.md b/.planning/phases/02-authentication-foundation/02-06-PLAN.md new file mode 100644 index 0000000..3defede --- /dev/null +++ b/.planning/phases/02-authentication-foundation/02-06-PLAN.md @@ -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" +--- + + +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. + + + +@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md +@/Users/rwilk/.codex/get-shit-done/templates/summary.md + + + +@.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 + + + + + + Task 1: Write AuthSession state-machine tests + + - 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) + + composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt + + - 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. + + + 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. + + + ./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*" + + + - `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 + + State-machine tests cover AUTH-04/AUTH-05 and validation Wave 0 requirements. + + + + Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client + + - 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) + + 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 + + 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 ` 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`, `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. + + + ./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*" + + + - `grep -q 'StateFlow' 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 + + Common auth runtime passes the state-machine tests and supports transparent refresh. + + + + Task 3: Wire authModule into Koin + + - 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) + + composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt + + 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. + + + ./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 + + + - `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 + + AuthSession and collaborators are available as Koin singletons for the UI gate. + + + + + +## 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 | + + + +Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`. + + + +AUTH-04 and AUTH-05 work at the session layer: persisted tokens restore, refresh failures clear state, and logout wipes recoverable refresh tokens. + + + +After completion, create `.planning/phases/02-authentication-foundation/02-06-SUMMARY.md`. + diff --git a/.planning/phases/02-authentication-foundation/02-07-PLAN.md b/.planning/phases/02-authentication-foundation/02-07-PLAN.md new file mode 100644 index 0000000..26d27d5 --- /dev/null +++ b/.planning/phases/02-authentication-foundation/02-07-PLAN.md @@ -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" +--- + + +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. + + + +@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md +@/Users/rwilk/.codex/get-shit-done/templates/summary.md + + + +@.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 + + + + + + Task 1: Add Compose Resources, theme seed, and ViewModel tests + + - .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 + + 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 + + - 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. + + + 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. + + + ./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*" + + + - `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 + + Resource and theme foundations match UI-SPEC and login error mapping is tested. + + + + Task 2: Implement auth screens, ViewModels, and App auth gate + + - .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 + + 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 + + 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. + + + ./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 + + + - `! 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 + + Auth gate UI compiles, uses resources, and has no dangling reference to a missing plan. + + + + Task 3: Manual iOS Authentik UAT + + - docs/authentik-setup.md + - .planning/phases/02-authentication-foundation/02-VALIDATION.md (Manual-Only Verifications) + + docs/authentik-setup.md, .planning/phases/02-authentication-foundation/02-07-SUMMARY.md + + 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. + + + ./gradlew check + + + - `./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 + + Phase 2 end-to-end auth flow: Authentik login, secure token persistence, server /me, and logout UI. + Follow the six UAT steps in the action block using the real Authentik provider configured from docs/authentik-setup.md. + Type "approved" if UAT passes, or describe the failing step and observed behavior. + Automated tests are green and the user confirms fresh login, persisted session refresh, logout, and /api/v1/me behavior. + + + + + +## 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 | + + + +Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`, then `./gradlew check`, then complete iOS Authentik UAT. + + + +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. + + + +After completion, create `.planning/phases/02-authentication-foundation/02-07-SUMMARY.md`. +