diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 891cb9f..db90a47 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -79,7 +79,7 @@ Plans:
### Phase 3: Households, Membership & Server Data Foundation
-**Goal:** Introduce the tenancy model before any feature tables land — `users`, `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household.
+**Goal:** Introduce the tenancy model before any feature tables land — `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household.
**Depends on:** Phase 2
**Requirements:** HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05
**Success Criteria** (what must be TRUE):
@@ -88,7 +88,14 @@ 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:** TBD
+**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
**UI hint:** yes
**Research flag:** no
@@ -213,7 +220,7 @@ Plans:
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Project Infrastructure & Module Wiring | 7/7 | Complete | 2026-04-24 |
-| 2. Authentication Foundation | 0/0 | Not started | - |
+| 2. Authentication Foundation | 0/5 | 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-01-PLAN.md b/.planning/phases/02-authentication-foundation/02-01-PLAN.md
new file mode 100644
index 0000000..214f328
--- /dev/null
+++ b/.planning/phases/02-authentication-foundation/02-01-PLAN.md
@@ -0,0 +1,215 @@
+---
+phase: 02-authentication-foundation
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - gradle/libs.versions.toml
+ - shared/build.gradle.kts
+ - 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
+ - shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt
+ - composeApp/build.gradle.kts
+ - server/build.gradle.kts
+ - docs/authentik-setup.md
+autonomous: true
+requirements: [AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06]
+user_setup:
+ - service: authentik
+ why: "OIDC provider for mobile login and server JWT validation"
+ env_vars:
+ - name: OIDC_ISSUER
+ source: "Authentik provider issuer URL"
+ - name: OIDC_AUDIENCE
+ source: "Authentik OAuth2 provider client ID"
+ - name: OIDC_JWKS_URL
+ source: "Optional JWKS URI from Authentik OpenID configuration"
+ dashboard_config:
+ - task: "Create public OAuth2/OIDC provider with PKCE S256, redirect URI recipe://callback, scopes openid profile email offline_access, RS256 signing, single-string audience equal to client_id"
+ location: "Authentik Admin -> Applications -> Providers"
+must_haves:
+ truths:
+ - "All Phase 2 plans compile against one shared OIDC config and one /api/v1/me DTO contract"
+ - "Authentik provider setup documents public client + PKCE S256, scopes openid profile email offline_access, RS256, single-string audience, JWKS, and end-session"
+ - "Android secure token storage is explicit: auth code must not use no-arg Settings() for tokens"
+ artifacts:
+ - path: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt"
+ provides: "OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI, API_BASE_URL per D-11"
+ contains: "OIDC_REDIRECT_URI"
+ - path: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt"
+ provides: "Serializable /api/v1/me response per D-27"
+ contains: "@Serializable"
+ - path: "docs/authentik-setup.md"
+ provides: "Provider scope mapping and manual UAT checklist per D-10"
+ contains: "offline_access"
+ key_links:
+ - from: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt"
+ to: "docs/authentik-setup.md"
+ via: "same issuer/client/redirect values"
+ pattern: "recipe://callback"
+ - from: "gradle/libs.versions.toml"
+ to: "composeApp/build.gradle.kts and server/build.gradle.kts"
+ via: "catalog aliases only; no version literals in module build files"
+ pattern: "ktor-serverAuthJwt|appauth|androidx-security-crypto"
+---
+
+
+Create the shared contract and dependency foundation for Authentication Foundation.
+
+Purpose: every downstream plan needs the same DTOs, dependency aliases, and Authentik provider contract before implementation starts.
+Output: shared DTO/config files, build dependency wiring, and `docs/authentik-setup.md`.
+
+
+
+@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
+@/Users/rwilk/.codex/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.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
+@AGENTS.md
+
+
+
+
+
+ Task 1: Add shared DTO/config contract and serialization test
+
+ - shared/build.gradle.kts
+ - shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt
+ - shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt
+ - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-11, D-27, D-28)
+
+ shared/build.gradle.kts, 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, shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt
+
+ - `Constants.OIDC_REDIRECT_URI` equals exactly `recipe://callback` per D-09.
+ - `Constants.OIDC_ISSUER` ends with `/`; use placeholder `https://auth.example.invalid/application/o/recipe/` until real homelab value is substituted.
+ - `Constants.OIDC_CLIENT_ID` equals `recipe-app`.
+ - `MeResponse` serializes fields `id`, `sub`, `email`, `displayName`, and maps to `User`.
+ - `shared/commonMain` imports only allowed dependencies: Kotlin stdlib and kotlinx.serialization.
+
+
+ Apply `alias(libs.plugins.kotlinSerialization)` to `shared/build.gradle.kts`; add `api(libs.kotlinx.serializationJson)` in `commonMain.dependencies`.
+
+ Add `dev.ulfrx.recipe.shared.Constants` as a public object with `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URI`, and `API_BASE_URL`. Add `dev.ulfrx.recipe.shared.dto.User` and `MeResponse` as public `@Serializable` data classes using `String` for the server UUID, with `MeResponse.toUser()`.
+
+ Create `MeResponseSerializationTest` covering round trip, `displayName` wire name, and `ignoreUnknownKeys` compatibility with future `householdId`.
+
+
+ ./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata
+
+
+ - `grep -q 'OIDC_REDIRECT_URI: String = "recipe://callback"' shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt`
+ - `grep -q '@Serializable' shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt`
+ - `grep -q 'public fun toUser()' shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt`
+ - `./tools/verify-shared-pure.sh` exits 0
+ - `./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata` exits 0
+
+ Shared config and DTO contract exists and is tested without violating shared module purity.
+
+
+
+ Task 2: Add Phase 2 dependency aliases without Ktor patch bump
+
+ - gradle/libs.versions.toml
+ - composeApp/build.gradle.kts
+ - server/build.gradle.kts
+ - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Standard Stack, Open Questions)
+
+ gradle/libs.versions.toml, composeApp/build.gradle.kts, server/build.gradle.kts
+
+ In `gradle/libs.versions.toml`, keep existing `ktor = "3.4.1"` unchanged. Add version keys and aliases for:
+ `appauth = "0.11.1"`, `androidx-security-crypto = "1.1.0"`, `multiplatformSettings = "1.3.0"`, `exposed = "0.55.0"`, `hikari = "6.2.1"`, plus `kotlinCocoapods` plugin.
+
+ Add libraries: `appauth`, `androidx-security-crypto`, `multiplatform-settings`, `multiplatform-settings-coroutines`, Ktor client core/auth/content-negotiation/logging/okhttp/darwin/cio, `ktor-serializationKotlinxJsonMpp` using `io.ktor:ktor-serialization-kotlinx-json`, Ktor server auth/auth-jwt/call-logging/status-pages, Exposed core/jdbc/java-time, Hikari, `kotlinx-serializationJson`.
+
+ In `composeApp/build.gradle.kts`, apply `alias(libs.plugins.kotlinSerialization)` and `alias(libs.plugins.kotlinCocoapods)`. Add common deps for settings, Ktor client, serialization, and platform deps: Android AppAuth + AndroidX Security Crypto + OkHttp, iOS Darwin, JVM CIO. Configure CocoaPods with `podfile = project.file("../iosApp/Podfile")`, framework `baseName = "ComposeApp"`, `isStatic = true`, and `pod("AppAuth") { version = "2.0.0" }`.
+
+ In `server/build.gradle.kts`, add server auth/JWT/call logging/status pages, Exposed, Hikari, and serialization deps from catalog. Do not add inline versions in build files.
+
+
+ ./gradlew :composeApp:dependencies --configuration androidMainCompileClasspath :server:dependencies --configuration runtimeClasspath
+
+
+ - `grep -q 'ktor = "3.4.1"' gradle/libs.versions.toml`
+ - `grep -q 'androidx-security-crypto' gradle/libs.versions.toml`
+ - `grep -q 'pod("AppAuth")' composeApp/build.gradle.kts`
+ - `grep -q 'libs.androidx.security.crypto' composeApp/build.gradle.kts`
+ - `grep -q 'libs.ktor.serverAuthJwt' server/build.gradle.kts`
+ - `grep -q 'libs.exposed.jdbc' server/build.gradle.kts`
+ - `./tools/verify-no-version-literals.sh` exits 0
+
+ Phase 2 dependencies are cataloged and wired while preserving the pinned Ktor version.
+
+
+
+ Task 3: Document Authentik provider setup and source audit
+
+ - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-05 through D-10, D-19, D-21 through D-23)
+ - .planning/phases/02-authentication-foundation/02-VALIDATION.md
+ - .planning/ROADMAP.md Phase 2 success criteria
+
+ docs/authentik-setup.md
+
+ Create `docs/authentik-setup.md` with these exact sections:
+ `## Provider`, `## Scopes`, `## Redirect URI`, `## Server Env Vars`, `## Logout`, `## Manual UAT`, `## Source Audit`.
+
+ Provider section must specify: OAuth2/OIDC public client, authorization code with PKCE S256, no client secret in the app, redirect URI `recipe://callback`, RS256 signing, single-string `aud` equal to `recipe-app`, JWKS URI from the provider's OpenID configuration, and end-session endpoint.
+
+ Scopes section must state the app requests exactly `openid profile email offline_access` and that Authentik must map/allow `offline_access` for refresh tokens. Manual UAT must cover fresh iOS login, reopen/refresh after access-token expiry, logout returning to login, and curl/HTTP verification of `/api/v1/me` returning 200 with valid token and 401 without/wrong-audience token.
+
+ Source Audit must mark all Phase 2 sources covered: GOAL Phase 2, REQ AUTH-01..AUTH-06, RESEARCH constraints, CONTEXT D-01..D-34, UI-SPEC auth screens, VALIDATION Wave 0 tests, PATTERNS file map. Deferred ideas must be listed as excluded: Universal Links/App Links, real Desktop OIDC, Wasm OIDC, Apple Sign-in, Authentik automation.
+
+
+ grep -E 'openid profile email offline_access|PKCE S256|single-string|recipe://callback|/api/v1/me|Source Audit' docs/authentik-setup.md
+
+
+ - `grep -q 'openid profile email offline_access' docs/authentik-setup.md`
+ - `grep -q 'offline_access.*refresh' docs/authentik-setup.md`
+ - `grep -q 'single-string.*aud' docs/authentik-setup.md`
+ - `grep -q 'AUTH-01.*AUTH-02.*AUTH-03.*AUTH-04.*AUTH-05.*AUTH-06' docs/authentik-setup.md`
+ - `grep -q 'Universal Links / App Links.*excluded' docs/authentik-setup.md`
+
+ Authentik setup and multi-source audit are reproducible and trace every locked requirement/decision.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| app -> Authentik | Mobile app launches system browser and receives authorization callback through custom URL scheme |
+| app -> OS secure storage | Refresh tokens cross from process memory to persistent device storage |
+| client -> server | Bearer access tokens cross HTTP boundary |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-01-01 | Spoofing/Elevation | OIDC provider setup | mitigate | Document public client + PKCE S256 + AppAuth state handling + exact `recipe://callback` registration |
+| T-02-01-02 | Information Disclosure | token storage dependencies | mitigate | Explicit AndroidX Security Crypto and iOS Keychain store plan; forbid no-arg `Settings()` for auth tokens |
+| T-02-01-03 | Elevation | JWT audience config | mitigate | Document single-string `aud` equal to `recipe-app`; server tests in Plan 02 enforce wrong audience 401 |
+| T-02-01-04 | Information Disclosure | logs/docs | mitigate | Docs state never log `Authorization` or token bodies; server/client implementation plans include redaction |
+
+
+
+Run `./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata`, `./tools/verify-shared-pure.sh`, and `./tools/verify-no-version-literals.sh`.
+
+
+
+Downstream server, client, and UI plans have stable imports/config, Authentik setup is documented, Ktor remains at 3.4.1, and Android token security is explicit.
+
+
+
diff --git a/.planning/phases/02-authentication-foundation/02-02-PLAN.md b/.planning/phases/02-authentication-foundation/02-02-PLAN.md
new file mode 100644
index 0000000..de7bb00
--- /dev/null
+++ b/.planning/phases/02-authentication-foundation/02-02-PLAN.md
@@ -0,0 +1,218 @@
+---
+phase: 02-authentication-foundation
+plan: 02
+type: execute
+wave: 2
+depends_on: [02-01]
+files_modified:
+ - server/src/main/resources/application.conf
+ - server/src/main/resources/db/migration/V1__users.sql
+ - server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
+ - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
+ - server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt
+ - server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt
+ - server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt
+ - server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt
+ - server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt
+ - server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt
+ - server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt
+ - server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt
+autonomous: true
+requirements: [AUTH-03, AUTH-06]
+must_haves:
+ truths:
+ - "GET /api/v1/me with a valid Authentik-style token returns the JIT-provisioned user record"
+ - "GET /api/v1/me with missing, expired, wrong-issuer, wrong-audience, or blank-sub token returns 401"
+ - "First valid request creates a users row keyed by OIDC sub; later request updates email/display_name for the same sub"
+ - "Authorization headers and bearer token values are not logged"
+ artifacts:
+ - path: "server/src/main/resources/db/migration/V1__users.sql"
+ provides: "users table per D-24"
+ contains: "CREATE TABLE users"
+ - path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt"
+ provides: "Ktor jwt(\"authentik\") verifier with issuer, audience, leeway, JWKS cache/rate limit, non-empty sub"
+ exports: ["configureAuthentication"]
+ - path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt"
+ provides: "Exposed DSL JIT user upsert by sub per D-25/D-26"
+ exports: ["PrincipalResolver"]
+ - path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt"
+ provides: "Protected /api/v1/me route per D-27"
+ exports: ["meRoute"]
+ key_links:
+ - from: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt"
+ to: "server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt"
+ via: "install Authentication before route registration"
+ pattern: "configureAuthentication"
+ - from: "server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt"
+ to: "server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt"
+ via: "authenticated JWT principal resolves to users row"
+ pattern: "resolve"
+---
+
+
+Implement the Ktor server authentication boundary: Authentik JWT validation, JIT user provisioning, and the protected `/api/v1/me` endpoint.
+
+Purpose: satisfy AUTH-03 and AUTH-06 while establishing safe server auth patterns for Phase 3 household scoping.
+Output: Flyway users migration, auth plugin, resolver, route, and negative JWT/JIT tests.
+
+
+
+@/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-RESEARCH.md
+@.planning/phases/02-authentication-foundation/02-VALIDATION.md
+@.planning/phases/02-authentication-foundation/02-PATTERNS.md
+@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
+@AGENTS.md
+@server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
+@server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
+@server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
+
+
+
+
+
+ Task 1: Create JWT validation tests before auth implementation
+
+ - server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
+ - .planning/phases/02-authentication-foundation/02-VALIDATION.md (Wave 0 server tests)
+ - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-21, D-22, D-23)
+
+ server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt, server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt
+
+ - No Authorization header returns 401.
+ - Expired token returns 401.
+ - Wrong issuer returns 401.
+ - Wrong audience returns 401.
+ - Blank `sub` returns 401.
+ - Valid RS256 test token returns 200 from a protected test route.
+
+
+ Create `JwtTestSupport` to generate an RSA keypair, expose a local JWKS endpoint in `testApplication`, and mint RS256 JWTs with configurable `iss`, `aud`, `sub`, `email`, `name`, and expiry.
+
+ Create `AuthJwtTest` that installs `ContentNegotiation`, `configureAuthentication(AuthConfig(...test issuer/audience/jwks...))`, and a protected test route under `authenticate("authentik")`. Tests must assert the status codes listed in ``. Keep tests independent of Postgres and `Database.migrate`.
+
+ These tests should fail before `AuthPlugin.kt` exists; then continue to Task 2.
+
+
+ ./gradlew :server:test --tests "*AuthJwtTest*"
+
+
+ - `grep -q 'wrong audience' server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt`
+ - `grep -q 'blank sub' server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt`
+ - `grep -q 'RS256' server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt`
+ - After Task 2, `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0
+
+ JWT negative coverage exists for AUTH-03 and blocks wrong-audience/issuer regressions.
+
+
+
+ Task 2: Implement AuthConfig, JWT plugin, logging redaction, and verify Exposed suspend API
+
+ - server/src/main/resources/application.conf
+ - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
+ - server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
+ - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 4 Exposed API drift)
+
+ server/src/main/resources/application.conf, server/src/main/kotlin/dev/ulfrx/recipe/Application.kt, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt
+
+ Add `oidc { issuer, audience, jwksUrl, leewaySeconds }` to `application.conf` with env overrides `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL` and default leeway `30`.
+
+ Create `AuthConfig.fromApplicationConfig(config)` and `configureAuthentication(authConfig: AuthConfig = AuthConfig.fromApplicationConfig(environment.config))`. `AuthPlugin.kt` must install `jwt("authentik")` using `JwkProviderBuilder(jwksUrl or issuer).cached(10, 15, TimeUnit.MINUTES).rateLimited(10, 1, TimeUnit.MINUTES)`, `.withIssuer(issuer)`, `.withAudience(audience)`, `.acceptLeeway(30)`, and a validate block rejecting null/blank `sub`.
+
+ Install `CallLogging` in `Application.module()` and redact `Authorization`. Never log token bodies or raw Authorization headers.
+
+ Before implementing Task 3 DB code, verify the pinned Exposed suspend transaction API/import against the dependency used by this repo. Run:
+ `./gradlew :server:dependencyInsight --dependency exposed-jdbc --configuration runtimeClasspath`
+ Then inspect IDE/Gradle source or compile probe and use whichever import compiles for pinned Exposed: expected for the chosen catalog version is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, use that exact import and note it in `02-02-SUMMARY.md`. Do not use blocking `transaction {}` inside suspend route code.
+
+
+ ./gradlew :server:test --tests "*AuthJwtTest*"
+
+
+ - `grep -q 'OIDC_ISSUER' server/src/main/resources/application.conf`
+ - `grep -q 'jwt("authentik")' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
+ - `grep -q 'withAudience' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
+ - `grep -q 'acceptLeeway(30' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
+ - `grep -q 'rateLimited(10, 1, TimeUnit.MINUTES)' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
+ - `grep -q 'redactHeader(HttpHeaders.Authorization)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
+ - `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0
+
+ Server rejects invalid JWTs, accepts valid Authentik-style JWTs, and redacts Authorization headers.
+
+
+
+ Task 3: Add users migration, Exposed resolver, /api/v1/me route, and JIT tests
+
+ - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
+ - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-24 through D-27)
+ - server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
+ - server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt
+
+ server/src/main/resources/db/migration/V1__users.sql, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt, server/src/main/kotlin/dev/ulfrx/recipe/Application.kt, server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt
+
+ Create `V1__users.sql` exactly with `users(id UUID PRIMARY KEY DEFAULT gen_random_uuid(), sub TEXT NOT NULL UNIQUE, email TEXT NOT NULL, display_name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())` plus `CREATE INDEX users_sub_idx ON users(sub);`.
+
+ Add Exposed `UsersTable` using DSL only. Add `Database.connect(app)` using Hikari or direct Exposed connection after Flyway migration.
+
+ Implement `PrincipalResolver.resolve(jwtPrincipal)` as a suspend function that extracts non-empty `sub`, `email`, and `name`/`preferred_username` fallback, then performs atomic Postgres upsert by `sub` updating `email`, `display_name`, `updated_at = now()` and returning a `User`/`MeResponse`. Use the verified suspend transaction import from Task 2. Do not select-then-insert.
+
+ Add `meRoute(principalResolver)` under `authenticate("authentik") { get("/api/v1/me") { ... } }`. Wire route from `configureRouting`.
+
+ Create `MeRouteTest` for valid token creates row, second token same `sub` updates email/display name without duplicating, and valid response body contains `id`, `sub`, `email`, `displayName`.
+
+
+ ./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"
+
+
+ - `grep -q 'CREATE TABLE users' server/src/main/resources/db/migration/V1__users.sql`
+ - `grep -q 'sub TEXT NOT NULL UNIQUE' server/src/main/resources/db/migration/V1__users.sql`
+ - `! grep -R 'org.jetbrains.exposed.dao' server/src/main/kotlin/dev/ulfrx/recipe/auth`
+ - `! grep -R 'transaction {' server/src/main/kotlin/dev/ulfrx/recipe/auth`
+ - `grep -q 'ON CONFLICT (sub) DO UPDATE' server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt`
+ - `grep -q 'get("/api/v1/me")' server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt`
+ - `./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"` exits 0
+
+ `/api/v1/me` validates tokens, JIT-provisions users atomically, and returns the shared DTO.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| client -> Ktor | Untrusted bearer token arrives in Authorization header |
+| Ktor -> Authentik JWKS | Server fetches signing keys from Authentik |
+| Ktor -> Postgres | Authenticated claims become persisted user rows |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-02-01 | Elevation | JWT verifier | mitigate | Validate issuer, audience, expiry, RS256 signature, 30s leeway, and non-empty `sub`; negative tests for wrong audience/issuer/blank sub |
+| T-02-02-02 | Denial of Service | JWKS provider | mitigate | Configure cache size 10 / 15 min and rate limit 10 per minute per D-22 |
+| T-02-02-03 | Information Disclosure | server logs | mitigate | Ktor CallLogging redacts Authorization and code never logs bearer token bodies |
+| T-02-02-04 | Tampering | JIT provisioning | mitigate | Atomic upsert on unique `sub`; no client-supplied user ID |
+| T-02-02-05 | Repudiation | user updates | accept | Phase 2 records current `updated_at`; full audit log is out of scope for small household v1 |
+
+
+
+Run `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` and then `./gradlew :server:test`.
+
+
+
+AUTH-03 and AUTH-06 are satisfied: valid tokens return `/api/v1/me`, invalid tokens return 401, and user rows are created/updated by OIDC `sub`.
+
+
+
diff --git a/.planning/phases/02-authentication-foundation/02-03-PLAN.md b/.planning/phases/02-authentication-foundation/02-03-PLAN.md
new file mode 100644
index 0000000..322d3c3
--- /dev/null
+++ b/.planning/phases/02-authentication-foundation/02-03-PLAN.md
@@ -0,0 +1,200 @@
+---
+phase: 02-authentication-foundation
+plan: 03
+type: execute
+wave: 2
+depends_on: [02-01]
+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/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt
+ - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt
+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"
+ - "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"
+ 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: "iosApp/iosApp/Info.plist"
+ provides: "recipe URL scheme registration"
+ contains: "CFBundleURLSchemes"
+ 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"
+---
+
+
+Implement the platform OIDC and secure storage boundary for mobile auth.
+
+Purpose: satisfy AUTH-01/AUTH-02 platform requirements before `AuthSession` composes them into app state.
+Output: expect/actual OIDC client, explicit secure auth state store, URL callback registration, and platform stubs.
+
+
+
+@/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
+@AGENTS.md
+@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt
+@iosApp/iosApp/iOSApp.swift
+
+
+
+
+
+ Task 1: Define OidcClient and secure store common contracts
+
+ - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt
+ - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01..D-06, D-13..D-20)
+ - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 1)
+
+ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt
+
+ Create `OidcResult` sealed type with `Success(authStateJson: String, accessToken: String, idToken: String?, expiresAtEpochMillis: Long)`, `Cancelled`, `NetworkError`, and `AuthError`.
+
+ Create `expect class OidcClient` with `suspend fun login(): OidcResult`, `suspend fun refresh(authStateJson: String): OidcResult`, and `suspend fun logout(authStateJson: String): Unit`. The contract must state that native actuals use AppAuth and request scopes exactly `openid profile email offline_access`.
+
+ Create `expect class SecureAuthStateStore` with `fun read(): String?`, `fun write(authStateJson: String)`, and `fun clear()`. Add contract tests using a fake in-memory implementation to lock read/write/clear semantics; platform implementations compile in Task 2.
+
+
+ ./gradlew :composeApp:jvmTest
+
+
+ - `grep -q 'expect class OidcClient' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt`
+ - `grep -q 'openid profile email offline_access' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt`
+ - `grep -q 'expect class SecureAuthStateStore' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt`
+ - `./gradlew :composeApp:jvmTest` exits 0
+
+ Common auth platform seams exist with testable store semantics and exact scope contract.
+
+
+
+ Task 2: Implement Android and iOS AppAuth actuals with explicit secure storage
+
+ - composeApp/build.gradle.kts
+ - composeApp/src/androidMain/AndroidManifest.xml
+ - iosApp/iosApp/Info.plist
+ - iosApp/iosApp/iOSApp.swift
+ - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-09, D-13, D-14, D-19, D-20)
+
+ composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt, composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt, composeApp/src/androidMain/AndroidManifest.xml, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift, iosApp/Podfile
+
+ Android: implement AppAuth-Android using `AuthorizationServiceConfiguration.fetchFromIssuer`, `AuthorizationRequest.Builder`, `ResponseTypeValues.CODE`, `setScopes("openid", "profile", "email", "offline_access")`, AppAuth PKCE defaults, `suspendCancellableCoroutine`, `AuthState.jsonSerializeString()`, `AuthState.jsonDeserialize(...)`, `performActionWithFreshTokens`, and `EndSessionRequest` when metadata exposes end-session. Register `net.openid.appauth.RedirectUriReceiverActivity` for scheme `recipe` host `callback`.
+
+ Android secure storage decision: use AndroidX Security Crypto `EncryptedSharedPreferences` behind `SecureAuthStateStore.android.kt` for AUTH-02 because the requirement explicitly calls out Android EncryptedSharedPreferences. Document in code comment that the dependency is deprecated upstream but isolated behind `SecureAuthStateStore`; do not use no-arg `Settings()` or ordinary `SharedPreferences` for auth tokens.
+
+ iOS: implement AppAuth-iOS via CocoaPods/interop using `OIDAuthorizationService`, `OIDAuthState`, `OIDAuthorizationRequest`, `OIDTokenRequest` refresh/fresh-token helpers, `OIDEndSessionRequest`, and `suspendCancellableCoroutine`. Register `CFBundleURLTypes` for `recipe`. Add SwiftUI `.onOpenURL` or app delegate bridge in `iOSApp.swift` to resume the current AppAuth flow.
+
+ iOS secure storage: implement Keychain read/write/delete with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. Persist the full AppAuth AuthState JSON blob per D-13.
+
+
+ ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64
+
+
+ - `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
+ - `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth`
+ - `grep -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt`
+ - `grep -q 'offline_access' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
+ - `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
+ - `grep -q 'recipe' composeApp/src/androidMain/AndroidManifest.xml`
+ - `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist`
+ - `./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0
+
+ Mobile targets compile with AppAuth login/refresh/logout and explicit secure AuthState persistence.
+
+
+
+ Task 3: Add JVM and Wasm target actuals
+
+ - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-02, D-03)
+ - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
+ - composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
+
+ composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt
+
+ JVM actual reads `DEV_AUTH_TOKEN` from environment and returns `OidcResult.Success(authStateJson = "dev:$token", accessToken = token, idToken = null, expiresAtEpochMillis = Long.MAX_VALUE)` when present. If missing, return `OidcResult.AuthError("DEV_AUTH_TOKEN is not set")`; do not hardcode a usable bearer token.
+
+ Wasm actual throws exactly `NotImplementedError("Wasm OIDC: v2")` for login/refresh/logout per D-03.
+
+
+ ./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs
+
+
+ - `grep -q 'DEV_AUTH_TOKEN' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt`
+ - `grep -q 'NotImplementedError("Wasm OIDC: v2")' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt`
+ - `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0
+
+ Secondary targets compile without expanding Phase 2 scope into real Desktop/Wasm OIDC.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| system browser -> app | Authorization code returns through custom URL scheme |
+| app process -> OS secure storage | AuthState JSON containing refresh token is persisted |
+| app -> Authentik | Refresh and end-session requests exchange tokens with IdP |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-03-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; redirect URI byte-matched to `recipe://callback` |
+| T-02-03-02 | Information Disclosure | Android token store | mitigate | Use EncryptedSharedPreferences behind `SecureAuthStateStore`; grep forbids no-arg `Settings()` in auth |
+| T-02-03-03 | Information Disclosure | iOS token store | mitigate | Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` |
+| T-02-03-04 | Information Disclosure | AppAuth diagnostics | mitigate | Do not log AuthState JSON, access tokens, refresh tokens, id tokens, or Authorization headers |
+| T-02-03-05 | Spoofing | JVM dev stub | accept | Desktop is dev tool only; stub requires explicit `DEV_AUTH_TOKEN` and is not a release surface |
+
+
+
+Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :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.
+
+
+
diff --git a/.planning/phases/02-authentication-foundation/02-04-PLAN.md b/.planning/phases/02-authentication-foundation/02-04-PLAN.md
new file mode 100644
index 0000000..7eabb23
--- /dev/null
+++ b/.planning/phases/02-authentication-foundation/02-04-PLAN.md
@@ -0,0 +1,193 @@
+---
+phase: 02-authentication-foundation
+plan: 04
+type: execute
+wave: 3
+depends_on: [02-02, 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
+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 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.
+
+
+
+@/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-02-SUMMARY.md
+@.planning/phases/02-authentication-foundation/02-03-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-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 |
+
+
+
+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.
+
+
+
diff --git a/.planning/phases/02-authentication-foundation/02-05-PLAN.md b/.planning/phases/02-authentication-foundation/02-05-PLAN.md
new file mode 100644
index 0000000..0b8a56b
--- /dev/null
+++ b/.planning/phases/02-authentication-foundation/02-05-PLAN.md
@@ -0,0 +1,202 @@
+---
+phase: 02-authentication-foundation
+plan: 05
+type: execute
+wave: 4
+depends_on: [02-04]
+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-04-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-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.
+
+
+
+
+
+## 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-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 |
+
+
+
+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.
+
+
+