Files
recipe/.planning/phases/02-authentication-foundation/02-06-PLAN.md
2026-04-29 20:54:13 +02:00

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-authentication-foundation 06 execute 4
02-01
02-02
02-03
02-04
02-05
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
true
AUTH-01
AUTH-02
AUTH-04
AUTH-05
truths artifacts key_links
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
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt Loading/Unauthenticated/Authenticated(user, householdId?) state model per D-28 householdId
path provides exports
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt StateFlow auth owner per D-29
AuthSession
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt Ktor client bearer auth with refreshTokens per D-17 refreshTokens
path provides
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt GET /api/v1/me client returning MeResponse
from to via pattern
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt login/refresh/logout delegate to platform AppAuth seam oidcClient
from to via pattern
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt after login/restore, fetch /api/v1/me to build Authenticated(user, null) 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.

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

@.planning/PROJECT.md @.planning/REQUIREMENTS.md @.planning/phases/02-authentication-foundation/02-CONTEXT.md @.planning/phases/02-authentication-foundation/02-RESEARCH.md @.planning/phases/02-authentication-foundation/02-VALIDATION.md @.planning/phases/02-authentication-foundation/02-PATTERNS.md @.planning/phases/02-authentication-foundation/02-01-SUMMARY.md @.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 <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.
./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.

<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>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`.

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

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