---
phase: 02-authentication-foundation
plan: 06
type: execute
wave: 4
depends_on: [02-01, 02-02, 02-03, 02-04, 02-05]
files_modified:
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt
  - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
  - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves:
  truths:
    - "AuthSession starts in Loading and restores persisted AuthState JSON before deciding Authenticated or Unauthenticated"
    - "Authenticated state contains User and householdId = null in Phase 2"
    - "Authenticated API calls get fresh access tokens proactively and Ktor bearer auth can reactively refresh on 401"
    - "Refresh invalid_grant transitions silently to Unauthenticated"
    - "logout() attempts RP end-session and clears local AuthState even if end-session fails"
    - "AuthSession is a Koin singleton in authModule and wired into appModule"
  artifacts:
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt"
      provides: "Loading/Unauthenticated/Authenticated(user, householdId?) state model per D-28"
      contains: "householdId"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
      provides: "StateFlow auth owner per D-29"
      exports: ["AuthSession"]
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt"
      provides: "Ktor client bearer auth with refreshTokens per D-17"
      contains: "refreshTokens"
    - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt"
      provides: "GET /api/v1/me client returning MeResponse"
  key_links:
    - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
      to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt"
      via: "login/refresh/logout delegate to platform AppAuth seam"
      pattern: "oidcClient"
    - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt"
      to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt"
      via: "after login/restore, fetch /api/v1/me to build Authenticated(user, null)"
      pattern: "meClient"
---

<objective>
Build the common client auth runtime: `AuthSession`, authenticated Ktor client, `/api/v1/me` client, and Koin wiring.

Purpose: compose common contracts from Plan 03, Android/iOS OIDC/storage from Plans 04/05, and server `/api/v1/me` from Plan 02 into persistent app session behavior.
Output: tested common auth state machine and DI module.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-02-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-04-SUMMARY.md
@.planning/phases/02-authentication-foundation/02-05-SUMMARY.md
@AGENTS.md
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Write AuthSession state-machine tests</name>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt
    - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
    - .planning/phases/02-authentication-foundation/02-VALIDATION.md (AuthSessionTest)
  </read_first>
  <files>composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt</files>
  <behavior>
    - Empty store initializes `Loading -> Unauthenticated`.
    - Successful login writes AuthState JSON, calls `/api/v1/me`, and emits `Authenticated(user, householdId = null)`.
    - Existing store refreshes before `/api/v1/me` and emits Authenticated without login.
    - Refresh `invalid_grant` or AuthError clears store and emits Unauthenticated without UI error.
    - Logout calls `OidcClient.logout(authStateJson)` then clears store and emits Unauthenticated even when logout throws.
    - Login cancelled maps to a result the UI can render as cancelled.
  </behavior>
  <action>
    Create fakes for `OidcClient`, `SecureAuthStateStore`, and `MeClient`. Write tests for the exact behaviors above before production implementation. Keep tests in commonTest and avoid platform AppAuth classes.
  </action>
  <verify>
    <automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q 'invalid_grant\\|AuthError' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
    - `grep -q 'householdId' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt`
    - After Task 2, `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0
  </acceptance_criteria>
  <done>State-machine tests cover AUTH-04/AUTH-05 and validation Wave 0 requirements.</done>
</task>

<task type="auto">
  <name>Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client</name>
  <read_first>
    - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
    - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
    - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt
    - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
    - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-16, D-17, D-18, D-28, D-29)
  </read_first>
  <files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt</files>
  <action>
    Create sealed `AuthState` with `Loading`, `Unauthenticated`, `Authenticated(user: User, householdId: HouseholdId? = null)` and `typealias HouseholdId = String`.

    Implement `MeClient.getMe(accessToken: String? = null)` calling `GET ${Constants.API_BASE_URL}/api/v1/me`, decoding `MeResponse`, and mapping to `User`. If `accessToken` is supplied for tests/simple calls, attach `Authorization: Bearer <token>` without logging it.

    Implement `AuthHttpClient.create(authSession)` using Ktor Client `Auth { bearer { loadTokens { ... }; refreshTokens { ... }; sendWithoutRequest { request.url.host == Url(Constants.API_BASE_URL).host } } }`, ContentNegotiation JSON, and logging that redacts token-bearing headers.

    Implement `AuthSession` with `state: StateFlow<AuthState>`, `initialize()`, `login()`, `logout()`, `getAccessToken()`, `currentBearerTokens()`, and `refreshBearerTokens()`. `initialize()` reads stored AuthState JSON, refreshes with AppAuth, persists updated JSON, calls `/api/v1/me`, and emits `Authenticated(user, null)`. On refresh failure, clear the store and emit Unauthenticated silently. On logout, call `OidcClient.logout(storedJson)` first, then always clear.
  </action>
  <verify>
    <automated>./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q 'StateFlow<AuthState>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt`
    - `grep -q 'refreshTokens' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt`
    - `grep -q 'sendWithoutRequest' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt`
    - `! grep -R 'Authorization.*\\$' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth`
    - `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0
  </acceptance_criteria>
  <done>Common auth runtime passes the state-machine tests and supports transparent refresh.</done>
</task>

<task type="auto">
  <name>Task 3: Wire authModule into Koin</name>
  <read_first>
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
    - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt
    - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt
    - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-29)
  </read_first>
  <files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt</files>
  <action>
    Create `authModule = module { ... }` providing singleton `SecureAuthStateStore`, `OidcClient`, `MeClient`, `AuthSession`, and auth-related ViewModels only if their classes already exist. Wire `appModule` to include auth definitions without starting Koin from composables. If target-specific constructors need Android context/activity, use Koin platform APIs already available in Phase 1 Android bootstrap.
  </action>
  <verify>
    <automated>./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</automated>
  </verify>
  <acceptance_criteria>
    - `grep -q 'val authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt`
    - `grep -q 'authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`
    - `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0
  </acceptance_criteria>
  <done>AuthSession and collaborators are available as Koin singletons for the UI gate.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| AuthSession -> server | Access token attached to `/api/v1/me` |
| AuthSession -> secure store | Refresh-capable AuthState JSON persists across app launches |
| AuthSession -> UI | Auth failures influence rendered state and messages |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-06-01 | Information Disclosure | AuthHttpClient logging | mitigate | Redact Authorization and never log token values |
| T-02-06-02 | Information Disclosure | AuthSession logout | mitigate | Always clear stored AuthState after logout attempt, including end-session failure |
| T-02-06-03 | Denial of Service | refresh path | mitigate | Proactive refresh before calls and Ktor bearer reactive refresh on 401 |
| T-02-06-04 | Spoofing | `/api/v1/me` response | mitigate | Authenticated state is built only from server `MeResponse`, not client-decoded token claims |
| T-02-06-05 | Repudiation | silent invalid_grant | accept | Silent return to login is a locked UX decision D-18; auth warnings may log without secrets |
</threat_model>

<verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`.
</verification>

<success_criteria>
AUTH-04 and AUTH-05 work at the session layer: persisted tokens restore, refresh failures clear state, and logout wipes recoverable refresh tokens.
</success_criteria>

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