Add authentication

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 6684b7179d
commit e0af5f4053
92 changed files with 8140 additions and 208 deletions

View File

@@ -143,7 +143,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
| Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending |
| Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending |
| Glass/blur effects: Haze (`dev.chrisbanes.haze:haze`) | Purpose-built for glass UI in CMP; handles content capture + efficient re-blur; multiplatform | — Pending |
| Mobile OIDC: AppAuth (Android) + ASWebAuthenticationSession wrapper (iOS), exposed via KMP interface | Platform-native OAuth flows; no cross-platform auth library mature enough yet for this in 2026 | — Pending |
| Mobile OIDC: Lokksmith on Android/iOS, exposed via the KMP `OidcClient` interface | Keeps OIDC browser flow, PKCE, state/nonce, refresh, and end-session in Kotlin Multiplatform; removes the Swift AppAuth bridge and SwiftPM package from the iOS shell while still using ASWebAuthenticationSession underneath on iOS | — Pending |
### Server tech stack

View File

@@ -7,12 +7,12 @@
### Authentication & identity
- [ ] **AUTH-01**: User can sign in via the self-hosted Authentik instance using OIDC (authorization code flow with PKCE)
- [ ] **AUTH-02**: Client stores access + refresh tokens securely (iOS Keychain / Android EncryptedSharedPreferences)
- [ ] **AUTH-03**: Ktor server validates incoming access tokens via Authentik's JWKS endpoint (issuer, audience, expiry, signature, clock skew leeway)
- [ ] **AUTH-04**: User session persists across app launches without re-authentication (token refresh handled transparently)
- [ ] **AUTH-05**: User can sign out, which revokes local tokens and returns to the login screen
- [ ] **AUTH-06**: Users are JIT-provisioned in the server database on first successful login (by OIDC `sub` claim)
- [x] **AUTH-01**: User can sign in via the self-hosted Authentik instance using OIDC (authorization code flow with PKCE)
- [x] **AUTH-02**: Client stores access + refresh tokens securely (iOS Keychain / Android EncryptedSharedPreferences)
- [x] **AUTH-03**: Ktor server validates incoming access tokens via Authentik's JWKS endpoint (issuer, audience, expiry, signature, clock skew leeway)
- [x] **AUTH-04**: User session persists across app launches without re-authentication (token refresh handled transparently)
- [x] **AUTH-05**: User can sign out, which revokes local tokens and returns to the login screen
- [x] **AUTH-06**: Users are JIT-provisioned in the server database on first successful login (by OIDC `sub` claim)
### Household sharing
@@ -159,11 +159,11 @@ Populated during roadmap creation. Each v1 requirement maps to exactly one phase
| Requirement | Phase | Status |
|-------------|-------|--------|
| AUTH-01 | Phase 2: Authentication Foundation | Pending |
| AUTH-02 | Phase 2: Authentication Foundation | Pending |
| AUTH-01 | Phase 2: Authentication Foundation | Complete |
| AUTH-02 | Phase 2: Authentication Foundation | Complete |
| AUTH-03 | Phase 2: Authentication Foundation | Pending |
| AUTH-04 | Phase 2: Authentication Foundation | Pending |
| AUTH-05 | Phase 2: Authentication Foundation | Pending |
| AUTH-04 | Phase 2: Authentication Foundation | Complete |
| AUTH-05 | Phase 2: Authentication Foundation | Complete |
| AUTH-06 | Phase 2: Authentication Foundation | Pending |
| HSHD-01 | Phase 3: Households, Membership & Server Data Foundation | Pending |
| HSHD-02 | Phase 3: Households, Membership & Server Data Foundation | Pending |

View File

@@ -73,13 +73,22 @@ 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:
- [x] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit
- [x] 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 OIDC actual, Android secure AuthState store, and manifest callback
- [ ] 02-05-PLAN.md — iOS OIDC actual, iOS Keychain store, and URL scheme callback
- [ ] 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
### 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):
@@ -213,7 +222,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 | 2/7 | Executing | - |
| 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 | - |

View File

@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
current_plan: 7
status: phase-complete
last_updated: "2026-04-24T18:56:34.969Z"
status: executing
last_updated: "2026-04-28T14:57:40.504Z"
progress:
total_phases: 11
completed_phases: 1
total_plans: 7
completed_plans: 7
percent: 100
total_plans: 14
completed_plans: 13
percent: 93
---
# Project State: Recipe
@@ -25,13 +25,13 @@ progress:
## Current Position
Phase: 01 — Project Infrastructure & Module Wiring — COMPLETE
Phase: 02 (authentication-foundation) — EXECUTING
Plan: 7 of 7
**Current focus:** Phase 1 automated gate complete
**Current focus:** Phase 02 — authentication-foundation
**Current plan:** 7
**Status:** Phase 1 complete; ready to plan Phase 2
**Phase progress:** 1 / 11 phases complete
**Progress bar:** `██░░░░░░░░░░░░░░░░░░` 9%
**Status:** Ready to execute
**Phase progress:** 6 / 7 plans complete
**Progress bar:** `[█████████░] 93%`
## Performance Metrics
@@ -41,7 +41,10 @@ Plan: 7 of 7
| v1 requirements | 72 |
| Coverage | 100% |
| Phases complete | 1 |
| Plans complete | 7 |
| Plans complete | 13 |
| Phase 02 P02 | 13min | 3 tasks | 14 files |
| Phase 02-authentication-foundation P03 | 31m | 2 tasks | 8 files |
| Phase 02-authentication-foundation P06 | 34m | 3 tasks | 7 files |
## Accumulated Context
@@ -59,17 +62,17 @@ All locked tech-stack decisions are captured in `.planning/PROJECT.md § Key Dec
## Session Continuity
**Last session:** Completed 01-07-PLAN.md
**Last session:** 2026-04-28T14:57:40.504Z
**Next action:** `/gsd-discuss-phase 2` or `/gsd-plan-phase 2` — Authentication Foundation.
**Next action:** `/gsd-execute-phase 2` — Authentication Foundation plan 07.
**Research flags to revisit during phase planning:**
**Research flags to revisit during future phase planning:**
- Phase 2 (Auth): Authentik-specific OIDC setup; iOS OIDC wrapper library choice; token refresh behavior.
- Phase 4 (SyncEngine): concrete cursor format, outbox schema ordering guarantees, retry/backoff policy.
- Phase 10 (UI chrome): current Haze CMP-iOS perf on iPhone 11/12-era hardware; liquid-glass approximation patterns.
---
*Last updated: 2026-04-24*
*Last updated: 2026-04-28*
**Planned Phase:** 1 (Project Infrastructure & Module Wiring) — 7 plans — 2026-04-24T16:07:36.289Z
**Planned Phase:** 2 (Authentication Foundation) — 7 plans — 2026-04-28T08:30:48.000Z

View File

@@ -0,0 +1,220 @@
---
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"
---
<objective>
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`.
</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/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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add shared DTO/config contract and serialization test</name>
<read_first>
- 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)
</read_first>
<files>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</files>
<behavior>
- `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.
</behavior>
<action>
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`.
</action>
<verify>
<automated>./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Shared config and DTO contract exists and is tested without violating shared module purity.</done>
</task>
<task type="auto">
<name>Task 2: Add Phase 2 dependency aliases without Ktor patch bump</name>
<read_first>
- gradle/libs.versions.toml
- composeApp/build.gradle.kts
- server/build.gradle.kts
- .planning/phases/02-authentication-foundation/02-RESEARCH.md (Standard Stack, Open Questions)
</read_first>
<files>gradle/libs.versions.toml, composeApp/build.gradle.kts, server/build.gradle.kts</files>
<action>
In `gradle/libs.versions.toml`, keep existing `ktor = "3.4.1"` unchanged. Add version keys and aliases for:
`appauth = "0.11.1"`, `appauth-ios = "2.0.0"`, `androidx-security-crypto = "1.1.0"`, `multiplatformSettings = "1.3.0"`, `exposed = "0.55.0"`, `hikari = "6.2.1"`, `testcontainers = "1.21.4"`, 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`, and Testcontainers `org.testcontainers:postgresql` + `org.testcontainers:junit-jupiter`.
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 = libs.versions.appauth.ios.get() }`. Do not put a literal `version = "2.0.0"` in any `*.gradle.kts`; the CocoaPods version must come from the version catalog so `./tools/verify-no-version-literals.sh` can pass.
In `server/build.gradle.kts`, add server auth/JWT/call logging/status pages, Exposed, Hikari, serialization deps, and test deps `testImplementation(libs.testcontainers.postgresql)` plus `testImplementation(libs.testcontainers.junit.jupiter)` from catalog. Do not add inline versions in build files.
</action>
<verify>
<automated>./gradlew :composeApp:dependencies --configuration androidMainCompileClasspath :server:dependencies --configuration runtimeClasspath</automated>
</verify>
<acceptance_criteria>
- `grep -q 'ktor = "3.4.1"' gradle/libs.versions.toml`
- `grep -q 'appauth-ios = "2.0.0"' gradle/libs.versions.toml`
- `grep -q 'androidx-security-crypto' gradle/libs.versions.toml`
- `grep -q 'testcontainers = "1.21.4"' gradle/libs.versions.toml`
- `grep -q 'pod("AppAuth")' composeApp/build.gradle.kts`
- `grep -q 'libs.versions.appauth.ios.get()' composeApp/build.gradle.kts`
- `! grep -q 'version = "2.0.0"' 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`
- `grep -q 'libs.testcontainers.postgresql' server/build.gradle.kts`
- `./tools/verify-no-version-literals.sh` exits 0
</acceptance_criteria>
<done>Phase 2 dependencies are cataloged and wired while preserving the pinned Ktor version.</done>
</task>
<task type="auto">
<name>Task 3: Document Authentik provider setup and source audit</name>
<read_first>
- .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
</read_first>
<files>docs/authentik-setup.md</files>
<action>
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.
</action>
<verify>
<automated>grep -E 'openid profile email offline_access|PKCE S256|single-string|recipe://callback|/api/v1/me|Source Audit' docs/authentik-setup.md</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<done>Authentik setup and multi-source audit are reproducible and trace every locked requirement/decision.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
Run `./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata`, `./tools/verify-shared-pure.sh`, and `./tools/verify-no-version-literals.sh`.
</verification>
<success_criteria>
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.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,253 @@
---
phase: 02-authentication-foundation
plan: 01
subsystem: auth
tags: [oidc, authentik, kotlinx-serialization, kmp, ktor, gradle-version-catalog, cocoapods, appauth, exposed, testcontainers]
requires:
- phase: 01-project-infrastructure-module-wiring
provides: gradle version catalog, recipe.kotlin.multiplatform convention plugin, shared module scaffold (D-19), Koin/Kermit bootstrap, Ktor server skeleton with ContentNegotiation + Database.migrate, verify-shared-pure.sh + verify-no-version-literals.sh
provides:
- dev.ulfrx.recipe.shared.Constants with OIDC_ISSUER (trailing slash), OIDC_CLIENT_ID = recipe-app, OIDC_REDIRECT_URI = recipe://callback, API_BASE_URL, SERVER_PORT (D-11)
- dev.ulfrx.recipe.shared.dto.User (domain identity)
- dev.ulfrx.recipe.shared.dto.MeResponse (@Serializable wire DTO with toUser() per D-27)
- shared/build.gradle.kts wired with kotlinSerialization plugin and api(libs.kotlinx.serializationJson)
- Phase 2 dependency aliases in gradle/libs.versions.toml (AppAuth, AndroidX Security Crypto, multiplatform-settings + coroutines, Exposed core/jdbc/java-time, HikariCP, Testcontainers postgresql + junit-jupiter, Ktor server auth/JWT/CallLogging/StatusPages, Ktor client core/auth/content-negotiation/logging/okhttp/darwin/cio + MPP serialization-kotlinx-json) without bumping Ktor (stays 3.4.1)
- composeApp/build.gradle.kts with kotlinSerialization + kotlin.native.cocoapods applied, cocoapods block bringing AppAuth-iOS via libs.versions.appauth.ios.get(), Phase 2 commonMain/androidMain/iosMain/jvmMain dependency wiring, manifestPlaceholders["appAuthRedirectScheme"] = "recipe", and locked compose.resources packageOfResClass
- server/build.gradle.kts with Ktor auth/JWT/CallLogging/StatusPages, Exposed DSL trio, Hikari, kotlinx.serialization-json, plus Testcontainers test dependencies
- docs/authentik-setup.md — reproducible Authentik OIDC provider playbook (D-10) with mandatory sections Provider/Scopes/Redirect URI/Server Env Vars/Logout/Manual UAT/Source Audit, plus an exhaustive multi-source audit table mapping AUTH-01..AUTH-06, CONTEXT D-01..D-34, RESEARCH constraints, UI-SPEC, VALIDATION Wave 0, and PATTERNS file map to either this doc or a downstream Phase 2 plan, with all deferred ideas explicitly excluded
affects:
- 02-02-PLAN.md (server JWT validation, JIT users, /api/v1/me — depends on shared MeResponse DTO + ktor server auth/jwt/exposed/hikari/testcontainers aliases)
- 02-03-PLAN.md (common OIDC/store contracts — depends on Constants, multiplatform-settings, ktor client, Coroutines stack)
- 02-04-PLAN.md (Android AppAuth actual + secure store — depends on libs.appauth + libs.androidx.security.crypto + manifestPlaceholders bootstrap)
- 02-05-PLAN.md (iOS AppAuth actual — depends on cocoapods AppAuth pod + libs.ktor.clientDarwin)
- 02-06-PLAN.md (LoginScreen/PostLoginPlaceholder UI — depends on MeResponse DTO and AuthSession contract from 02-03)
- 02-07-PLAN.md (integration glue / phase verification — depends on every prior plan)
tech-stack:
added:
- kotlinx-serialization-json (api scope in shared/commonMain)
- kotlinSerialization Gradle plugin on shared/composeApp/server (server already had it)
- kotlin.native.cocoapods plugin on composeApp (applied by id; bundled with KGP)
- AppAuth-Android (net.openid:appauth 0.11.1)
- AppAuth-iOS (CocoaPod 2.0.0) — Gradle CocoaPods DSL pulls it via libs.versions.appauth.ios.get()
- androidx.security:security-crypto 1.1.0 (Android secure AuthState store)
- com.russhwolf:multiplatform-settings + multiplatform-settings-coroutines 1.3.0
- Ktor client family (core/auth/content-negotiation/logging) + engines (okhttp Android, darwin iOS, cio JVM)
- Ktor server auth + auth-jwt + call-logging + status-pages (3.4.1, no patch bump)
- Exposed 0.55.0 (core + jdbc + java-time) and HikariCP 6.2.1
- Testcontainers 1.21.4 (postgresql + junit-jupiter)
patterns:
- Shared DTO contract pattern — kotlinx.serialization @Serializable data class with explicit camelCase wire keys, decoded with ignoreUnknownKeys for forward compat (Phase 3 will add householdId)
- Catalog-only version pinning — every Phase 2 dependency declared in gradle/libs.versions.toml; module build files reference libs.* only; verify-no-version-literals.sh enforces it
- Cocoapods-via-catalog pattern — pod("AppAuth") { version = libs.versions.appauth.ios.get() } keeps the build script literal-free even with native Pod integration
- Compose Resources package locking — explicit compose.resources { packageOfResClass = "..." } isolates UI code from build-script identity changes (group/version)
- Authentik provider audit — markdown audit table that traces every locked source (REQ/CONTEXT/RESEARCH/UI-SPEC/VALIDATION/PATTERNS) to either an in-doc anchor or a downstream plan, with deferred ideas explicitly listed
key-files:
created:
- 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
- docs/authentik-setup.md
modified:
- gradle/libs.versions.toml
- shared/build.gradle.kts
- composeApp/build.gradle.kts
- server/build.gradle.kts
- .gitignore (added *.podspec — generated by cocoapods plugin)
key-decisions:
- "Apply kotlin.native.cocoapods plugin by id, not via libs.plugins alias — the plugin ships inside the Kotlin Gradle plugin already on the classpath via recipe.kotlin.multiplatform; aliasing it forces a duplicate version request that Gradle rejects with 'already on the classpath, compatibility cannot be checked'."
- "Add manifestPlaceholders[\"appAuthRedirectScheme\"] = \"recipe\" to composeApp Android defaultConfig from Plan 02-01 (not Plan 02-04). AppAuth-Android's bundled manifest declares a ${appAuthRedirectScheme} placeholder that breaks AGP merge as soon as the dependency is on the classpath, even before any auth wiring. Setting it here is a Rule 3 prerequisite for the dependency to be cataloged."
- "Lock compose.resources packageOfResClass to the Phase 1 historical name. Adding top-level group = \"dev.ulfrx.recipe\" (required by the cocoapods plugin's podspec generator) shifts the generated Res-class package from recipe.composeapp.generated.resources to dev.ulfrx.recipe.composeapp.generated.resources, breaking Phase 1 App.kt imports. Locking the package keeps the diff inside Plan 02-01's stated files."
- "Ship a *.podspec gitignore entry. The Kotlin CocoaPods plugin regenerates composeApp/composeApp.podspec on every Gradle sync and that file legitimately contains 'AppAuth', '2.0.0' as a literal pin (CocoaPods semantics). Tracking it would either fail verify-no-version-literals.sh (if the verifier ever extends to *.podspec) or churn on every clean build."
- "kotlinx.serialization-json declared as api(...) in shared/commonMain so consumers (composeApp, server) inherit the @Serializable runtime without each re-declaring it. shared/commonMain stays free of Ktor / Compose / SQLDelight / Koin / Kermit per D-19 / INFRA-06."
- "Use the MPP variant of ktor-serialization-kotlinx-json for composeApp/commonMain (io.ktor:ktor-serialization-kotlinx-json) and keep the -jvm variant for the server module. Mixing variants between modules is the supported pattern; introducing a single MPP variant on the server breaks the existing ktor.serializationKotlinxJson alias used by the (jvm-only) server."
- "Server-side OIDC config (OIDC_ISSUER / OIDC_AUDIENCE / OIDC_JWKS_URL) is documented as env-var-driven in docs/authentik-setup.md (D-12) but the actual application.conf wiring is deferred to Plan 02-02. Plan 02-01 establishes the contract; 02-02 implements it."
patterns-established:
- "Shared DTO purity: shared/commonMain depends only on kotlin stdlib + kotlinx.serialization. Verified by ./tools/verify-shared-pure.sh."
- "Catalog discipline: every library and plugin version lives in gradle/libs.versions.toml; Gradle artifact identity (group/version) is allowed at the top of module build files but library/plugin pins are not. Verified by ./tools/verify-no-version-literals.sh."
- "TDD gate sequence: RED commit (test(02-01)) followed by GREEN commit (feat(02-01)) — the Phase 2 plans don't all use TDD but Plan 02-01's Task 1 sets the precedent for downstream auth plans."
- "Multi-source audit pattern: docs/authentik-setup.md ## Source Audit table is the template. Future phase docs that span multiple sources (REQ + CONTEXT + RESEARCH + VALIDATION + PATTERNS) should mirror this structure so audits stay reproducible."
requirements-completed: []
duration: 16m
completed: 2026-04-28
---
# Phase 02 Plan 01: Shared Auth Contracts, Dependency Aliases, and Authentik Setup Summary
**Phase 2 foundation: shared MeResponse/User DTOs + Constants, full Phase 2 dependency catalog (AppAuth/Exposed/Testcontainers/Ktor auth) wired into composeApp/server without bumping Ktor 3.4.1, plus the docs/authentik-setup.md reproducible-provider playbook with multi-source audit.**
## Performance
- **Duration:** 16 min
- **Started:** 2026-04-28T08:40:29Z
- **Completed:** 2026-04-28T08:55:58Z
- **Tasks:** 3 (Task 1 ran TDD: RED + GREEN)
- **Files modified:** 9 (5 created, 4 modified)
## Accomplishments
- Locked the `/api/v1/me` wire contract: `MeResponse` is `@Serializable`, decodes with `ignoreUnknownKeys` so Phase 3 can add `householdId` without breaking Phase 2 clients, and round-trips through `toUser()` to a stable domain `User`.
- Stood up `dev.ulfrx.recipe.shared.Constants` with `OIDC_ISSUER` (trailing slash placeholder host), `OIDC_CLIENT_ID = "recipe-app"`, `OIDC_REDIRECT_URI = "recipe://callback"`, plus `API_BASE_URL` and `SERVER_PORT` — the single config object every Phase 2 plan compiles against.
- Cataloged every Phase 2 dependency (AppAuth Android + iOS pod, AndroidX Security Crypto, multiplatform-settings, Ktor client/server auth family, Exposed DSL trio, Hikari, Testcontainers) and wired them into composeApp/server without bumping Ktor off `3.4.1`. CocoaPods integration brings AppAuth-iOS via `libs.versions.appauth.ios.get()` so no version literals leak into build files (`./tools/verify-no-version-literals.sh` stays green).
- Shipped `docs/authentik-setup.md` as a 240-line reproducible Authentik provider playbook covering Provider, Scopes, Redirect URI, Server Env Vars, Logout, Manual UAT (UAT-01..UAT-04), and a Source Audit table that traces every Phase 2 input (GOAL, AUTH-01..AUTH-06, CONTEXT D-01..D-34, RESEARCH, UI-SPEC, VALIDATION Wave 0, PATTERNS) to either this doc or a downstream Phase 2 plan, with all deferred ideas explicitly excluded.
## Task Commits
1. **Task 1 RED — Failing serialization test for MeResponse DTO**`6504b46` (test)
2. **Task 1 GREEN — Constants and MeResponse/User DTOs in shared**`7e73a9a` (feat)
3. **Task 2 — Phase 2 dependency aliases without bumping Ktor**`c1cc713` (feat)
4. **Task 3 — Authentik provider setup and Phase 2 source audit**`62040d4` (docs)
_Note: TDD task 1 produced two commits (RED then GREEN); no REFACTOR commit was needed because the GREEN implementation is already minimal._
## Files Created/Modified
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` — created. OIDC + API config object (D-11). Trailing-slash issuer, exact `recipe://callback` redirect URI, `recipe-app` client id (also `aud` per D-07).
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt` — created. Domain identity DTO; id is `String` (server UUID) so shared/commonMain stays free of UUID library deps.
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` — created. `@Serializable` wire DTO for `GET /api/v1/me` (D-27). One-to-one `toUser()` mapper. Forward-compatible with Phase 3 `householdId` via `ignoreUnknownKeys` decoders.
- `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt` — created. Three-test contract: round-trip via camelCase wire keys, Phase 3 forward-compat decode, `toUser()` no-data-loss mapping.
- `shared/build.gradle.kts` — modified. Applied `alias(libs.plugins.kotlinSerialization)`; added `api(libs.kotlinx.serializationJson)` so consumers inherit the runtime; `shared/commonMain` purity preserved (still no Ktor/Compose/SQLDelight/Koin/Kermit imports).
- `gradle/libs.versions.toml` — modified. Added Phase 2 versions/libraries/plugins per D-13/D-26/research; Ktor stays at 3.4.1.
- `composeApp/build.gradle.kts` — modified. Added kotlinSerialization + kotlin.native.cocoapods plugins; cocoapods block (AppAuth pod via catalog version); per-source-set Phase 2 deps; manifestPlaceholders for AppAuth-Android scheme; `compose.resources.packageOfResClass` lock to keep Phase 1 App.kt imports valid.
- `server/build.gradle.kts` — modified. Added Ktor server auth/JWT/CallLogging/StatusPages, Exposed core/jdbc/java-time, HikariCP, kotlinx.serialization-json, plus Testcontainers postgresql + junit-jupiter test deps.
- `docs/authentik-setup.md` — created. Reproducible Authentik playbook + Phase 2 source audit (D-10).
- `.gitignore` — modified. Ignore `*.podspec` (regenerated on every Gradle sync by the cocoapods plugin).
## Decisions Made
See frontmatter `key-decisions` for the load-bearing list. Highlights:
- **kotlin.native.cocoapods applied by id, not by alias** — the plugin ships inside KGP already on the classpath via `recipe.kotlin.multiplatform`, so a `libs.plugins.kotlinCocoapods` alias triggers a duplicate-version-request error.
- **manifestPlaceholders["appAuthRedirectScheme"] = "recipe"** lands in this plan, not 02-04 — it's a Rule 3 prerequisite for the AppAuth dependency to be cataloged at all.
- **`compose.resources.packageOfResClass` locked to Phase 1's historical package** — adding `group = "dev.ulfrx.recipe"` (mandatory for the cocoapods podspec generator) would otherwise rewrite the generated `Res` package and break `App.kt` imports.
- **Ktor stays at 3.4.1** — Open Question resolved during planning; auth artifacts catalog against the same `version.ref = "ktor"`. Patch bump deferred unless a concrete incompatibility appears.
- **Server OIDC config wiring deferred to Plan 02-02** — Plan 02-01 documents the env-var contract in `docs/authentik-setup.md` (`OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL`); Plan 02-02 implements it in `application.conf`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Apply kotlin.native.cocoapods by id, not via `alias(libs.plugins.kotlinCocoapods)`**
- **Found during:** Task 2 verification (`:composeApp:dependencies`)
- **Issue:** `Error resolving plugin [id: 'org.jetbrains.kotlin.native.cocoapods', version: '2.3.20']: The request for this plugin could not be satisfied because the plugin is already on the classpath with an unknown version, so compatibility cannot be checked.` The cocoapods plugin ships bundled with the Kotlin Gradle plugin classpath that `recipe.kotlin.multiplatform` already brings in.
- **Fix:** Apply via `id("org.jetbrains.kotlin.native.cocoapods")` (no version) inside the `plugins { ... }` block of `composeApp/build.gradle.kts`. Kept the `kotlinCocoapods` alias in `gradle/libs.versions.toml` per the plan's stated catalog additions; downstream plans can still reference `libs.versions.kotlinCocoapods.version` if they ever need the version programmatically.
- **Files modified:** composeApp/build.gradle.kts
- **Verification:** `:composeApp:dependencies` and `:composeApp:compileKotlinIosSimulatorArm64` (with `cinteropAppAuthIosSimulatorArm64`) now pass.
- **Committed in:** `c1cc713`
**2. [Rule 3 - Blocking] Add `manifestPlaceholders["appAuthRedirectScheme"] = "recipe"` to composeApp Android defaultConfig**
- **Found during:** Task 2 (`:composeApp:compileDebugKotlinAndroid`)
- **Issue:** `Manifest merger failed : Attribute data@scheme at AndroidManifest.xml requires a placeholder substitution but no value for <appAuthRedirectScheme> is provided.` AppAuth-Android's bundled manifest declares an unsubstituted `${appAuthRedirectScheme}` placeholder that breaks AGP's merger as soon as the dependency is on the classpath, even before Plan 02-04 wires the full `<intent-filter>`.
- **Fix:** Added the placeholder to `defaultConfig.manifestPlaceholders`. Value is `"recipe"`, byte-for-byte consistent with `Constants.OIDC_REDIRECT_URI = "recipe://callback"`. Plan 02-04 will still land the explicit `<intent-filter>` in the Android manifest; this placeholder satisfies AppAuth's *built-in* manifest entry until then.
- **Files modified:** composeApp/build.gradle.kts
- **Verification:** `:composeApp:compileDebugKotlinAndroid` now passes.
- **Committed in:** `c1cc713`
**3. [Rule 3 - Blocking] Lock `compose.resources.packageOfResClass` to the Phase 1 historical package**
- **Found during:** Task 2 (`:composeApp:compileDebugKotlinAndroid`, after the AppAuth manifest fix)
- **Issue:** Adding `group = "dev.ulfrx.recipe"` and `version = "1.0.0"` at the top of `composeApp/build.gradle.kts` (mandatory for the Kotlin CocoaPods plugin's podspec generator — `cocoapods` requires `project.version` if the block doesn't override it) shifted the Compose Resources `Res` class generated package from `recipe.composeapp.generated.resources` (Phase 1) to `dev.ulfrx.recipe.composeapp.generated.resources`, breaking `App.kt`'s `import recipe.composeapp.generated.resources.Res` and `compose_multiplatform`.
- **Fix:** Added an explicit `compose.resources { packageOfResClass = "recipe.composeapp.generated.resources" }` block to `composeApp/build.gradle.kts`, locking the generated package to the Phase 1 name regardless of `group`. This keeps Plan 02-01's diff inside its stated files; Plan 02-06 will replace `App.kt`'s template body with the real auth gate (D-30) and can choose to migrate the package then.
- **Files modified:** composeApp/build.gradle.kts
- **Verification:** `:composeApp:compileDebugKotlinAndroid` now passes; `App.kt` imports unchanged.
- **Committed in:** `c1cc713`
**4. [Rule 3 - Housekeeping] Ignore `*.podspec` files generated by the cocoapods plugin**
- **Found during:** Task 2 (`git status` after first cocoapods Gradle invocation)
- **Issue:** Adding the cocoapods plugin causes `composeApp/composeApp.podspec` to be regenerated on every Gradle sync. The file legitimately embeds `'AppAuth', '2.0.0'` as a literal CocoaPods version pin (CocoaPods Ruby DSL semantics), which would either fail `./tools/verify-no-version-literals.sh` if it ever extended to `.podspec` or churn on every clean build.
- **Fix:** Added `*.podspec` to `.gitignore` with an explanatory comment.
- **Files modified:** .gitignore
- **Verification:** `git status --short` shows no untracked `composeApp.podspec`.
- **Committed in:** `c1cc713`
**5. [Rule 1 - Bug] Strip "version = \"2.0.0\"" substring from a comment in composeApp/build.gradle.kts**
- **Found during:** Task 2 (`./tools/verify-no-version-literals.sh`)
- **Issue:** A comment paraphrased the Plan 02-01 acceptance criterion using the literal text `version = "2.0.0"`. The verifier doesn't distinguish comments from code and flagged the line. The plan's `! grep -q 'version = "2.0.0"' composeApp/build.gradle.kts` acceptance criterion ALSO reads from comments, so the comment was fundamentally incompatible with the rule.
- **Fix:** Rewrote the comment to describe the rule without quoting the forbidden literal pattern.
- **Files modified:** composeApp/build.gradle.kts
- **Verification:** `./tools/verify-no-version-literals.sh` exits 0; both `! grep` acceptance criteria now hold.
- **Committed in:** `c1cc713`
**6. [Rule 1 - Bug] Use `debugCompileClasspath` instead of nonexistent `androidMainCompileClasspath` in Task 2 verify command**
- **Found during:** Task 2 (`:composeApp:dependencies --configuration androidMainCompileClasspath`)
- **Issue:** Plan 02-01 Task 2 specifies `--configuration androidMainCompileClasspath`, but under the current AGP/Gradle/Kotlin combination the actual configuration name is `debugCompileClasspath` (or `releaseCompileClasspath`). The plan's command name doesn't exist in the configuration container.
- **Fix:** Ran the functionally equivalent command (`./gradlew :composeApp:dependencies --configuration debugCompileClasspath :server:dependencies --configuration runtimeClasspath`) which resolves the same Phase 2 deps the plan was checking for. Documented in the Task 2 commit message so a future planner can update the plan if needed.
- **Files modified:** none — this is a verification-command rename, not a code change.
- **Verification:** Both classpaths resolve cleanly and contain every Phase 2 dep (AppAuth, AndroidX Security Crypto, Ktor client family, Exposed core/jdbc/java-time, Hikari, Ktor server auth-jwt/call-logging/status-pages, Testcontainers).
- **Committed in:** N/A (procedural; no code change)
---
**Total deviations:** 6 auto-fixed (5 × Rule 3 blocking, 1 × Rule 1 bug, 1 × procedural rename).
**Impact on plan:** All deviations were unavoidable consequences of cataloging the AppAuth/CocoaPods dependency stack and integrating it with Phase 1's existing build setup. Net diff stays inside Plan 02-01's `files_modified` frontmatter list (plus `.gitignore`, which is a build-hygiene artifact). Zero scope creep into Plan 02-02..02-07.
## Issues Encountered
- **STATE.md drift from orchestrator init.** Running `gsd-sdk query init.execute-phase` at agent start mutated `.planning/STATE.md` (advanced `current_plan: 0 → 1`, status `planned → executing`). Per the parallel-execution rules, worktree agents must not modify `STATE.md`; the orchestrator owns those writes. Reverted via `git checkout -- .planning/STATE.md` before staging the first commit so the orchestrator's later state update is the single source of truth. No follow-up needed.
## User Setup Required
None — Plan 02-01 is wiring + docs only. The `docs/authentik-setup.md` Manual UAT section documents what the user will need to configure in Authentik before Plan 02-02..02-07 can be exercised end-to-end, but Plan 02-01 itself doesn't require any external service interaction.
## Next Phase Readiness
- **Plan 02-02 (server JWT validation, JIT users, /api/v1/me)** — All catalog dependencies and DTOs are in place. `MeResponse` DTO is importable from `dev.ulfrx.recipe.shared.dto`; Ktor server auth/JWT/CallLogging/StatusPages, Exposed DSL trio, Hikari, and Testcontainers are wired into `server/build.gradle.kts`. `application.conf` env-var contract is documented in `docs/authentik-setup.md` § Server Env Vars; Plan 02-02 implements it.
- **Plan 02-03 (common OIDC/store contracts, JVM/Wasm actuals)** — `Constants` and `multiplatform-settings` (+ coroutines) are available in `shared`/`composeApp/commonMain`. Ktor client core/auth/content-negotiation/logging are wired into commonMain.
- **Plan 02-04 (Android AppAuth actual + secure store)** — `libs.appauth` and `libs.androidx.security.crypto` are wired into `androidMain`. The `appAuthRedirectScheme=recipe` manifest placeholder is already set; Plan 02-04 only needs to add the explicit `<intent-filter>` and the `RedirectUriReceiverActivity` registration.
- **Plan 02-05 (iOS AppAuth actual)** — The cocoapods block is configured with the `AppAuth` pod at the catalog version; `libs.ktor.clientDarwin` is in `iosMain` deps. The `Info.plist` `CFBundleURLTypes` registration is the remaining iOS step.
- **Plan 02-06 (UI: SplashScreen / LoginScreen / PostLoginPlaceholderScreen)** — No blockers; Compose Resources package is locked to the Phase 1 historical name so existing `App.kt` keeps compiling. Plan 02-06 will replace `App.kt`'s template body with the auth gate (D-30) and optionally migrate the resources package then.
- **Plan 02-07 (integration glue / phase verification)** — All Phase 2 source files in `02-VALIDATION.md` Wave 0 will exist by the end of 02-02..02-06; this plan establishes the catalog and DTOs they depend on.
**No outstanding blockers.** Phase 2's per-plan execution can proceed.
## Self-Check: PASSED
- Created files exist:
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` — FOUND
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt` — FOUND
- `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` — FOUND
- `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt` — FOUND
- `docs/authentik-setup.md` — FOUND
- Modified files reflect intended changes:
- `gradle/libs.versions.toml` — FOUND (Phase 2 aliases present)
- `shared/build.gradle.kts` — FOUND (kotlinSerialization plugin + api(libs.kotlinx.serializationJson))
- `composeApp/build.gradle.kts` — FOUND (cocoapods + Phase 2 deps)
- `server/build.gradle.kts` — FOUND (Ktor auth/JWT, Exposed, Hikari, Testcontainers)
- `.gitignore` — FOUND (`*.podspec` ignore)
- Commits exist:
- `6504b46` (test RED) — FOUND
- `7e73a9a` (feat GREEN) — FOUND
- `c1cc713` (Task 2 wiring) — FOUND
- `62040d4` (Task 3 docs) — FOUND
- Plan-level verification:
- `./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata` — PASS
- `./tools/verify-shared-pure.sh` — PASS
- `./tools/verify-no-version-literals.sh` — PASS
- `./gradlew :composeApp:compileDebugKotlinAndroid :server:compileKotlin` — PASS
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` — PASS (`cinteropAppAuthIosSimulatorArm64` exercised the AppAuth pod end-to-end)
## TDD Gate Compliance
Plan 02-01 frontmatter has `type: execute` (not `type: tdd`), so plan-level RED/GREEN/REFACTOR enforcement does not apply. However, Task 1 was tagged `tdd="true"` and produced the expected gate sequence inside its scope:
- RED: `6504b46` (`test(02-01): add failing serialization test for MeResponse DTO`) — confirmed failing on `MeResponse` / `User` unresolved references.
- GREEN: `7e73a9a` (`feat(02-01): land Constants and MeResponse/User DTOs in shared`) — test now passes.
- REFACTOR: omitted; the GREEN implementation is already minimal.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,224 @@
---
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"
---
<objective>
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.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/ROADMAP.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create JWT validation tests before auth implementation</name>
<read_first>
- 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)
</read_first>
<files>server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt, server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt</files>
<behavior>
- 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.
</behavior>
<action>
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 `<behavior>`. Keep tests independent of Postgres and `Database.migrate`.
These tests should fail before `AuthPlugin.kt` exists; then continue to Task 2.
</action>
<verify>
<automated>./gradlew :server:test --tests "*AuthJwtTest*"</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>JWT negative coverage exists for AUTH-03 and blocks wrong-audience/issuer regressions.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement AuthConfig, JWT plugin, logging redaction, and verify Exposed suspend API</name>
<read_first>
- 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)
</read_first>
<files>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</files>
<action>
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()` using Ktor 3.4.1 APIs only. Configure `format { call -> "${call.request.httpMethod.value} ${call.request.path()} -> ${call.response.status()?.value ?: "-"}" }` so request/response method, path, and status are logged but headers are never included. Do not use `redactHeader(...)`; that API is not available on Ktor server `CallLoggingConfig` in the pinned Ktor 3.4.1 dependency. 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.
</action>
<verify>
<automated>./gradlew :server:test --tests "*AuthJwtTest*"</automated>
</verify>
<acceptance_criteria>
- `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 'install(CallLogging)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `grep -q 'format { call ->' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `! grep -q 'redactHeader' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `! grep -q 'HttpHeaders.Authorization' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0
</acceptance_criteria>
<done>Server rejects invalid JWTs, accepts valid Authentik-style JWTs, and redacts Authorization headers.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Add users migration, Exposed resolver, /api/v1/me route, and JIT tests</name>
<read_first>
- 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
</read_first>
<files>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</files>
<action>
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` with an explicit runnable PostgreSQL test database using Testcontainers, not ambient local Postgres. Use `org.testcontainers.containers.PostgreSQLContainer` with image `postgres:16`, start it in the test fixture, set the server/database config to the container JDBC URL, username, and password before installing routes, and stop it after tests. Run Flyway against the container before assertions. Tests must cover: valid token creates row, second token same `sub` updates email/display name without duplicating, and valid response body contains `id`, `sub`, `email`, `displayName`.
</action>
<verify>
<automated>./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"</automated>
</verify>
<acceptance_criteria>
- `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`
- `grep -q 'PostgreSQLContainer' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt`
- `grep -q 'postgres:16' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt`
- `grep -q 'Flyway' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt`
- `./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"` exits 0
</acceptance_criteria>
<done>`/api/v1/me` validates tokens, JIT-provisions users atomically, and returns the shared DTO.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
Run `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` and then `./gradlew :server:test`.
</verification>
<success_criteria>
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`.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-02-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,169 @@
---
phase: 02-authentication-foundation
plan: 02
subsystem: auth
tags: [ktor, jwt, authentik, jwks, postgres, flyway, exposed, testcontainers]
# Dependency graph
requires:
- phase: 02-01
provides: shared auth DTOs, dependency aliases, and Authentik setup context
provides:
- Authentik-style JWT validation with issuer, audience, expiry, RS256 signature, JWKS caching, and non-empty sub enforcement
- Flyway users table migration keyed by OIDC sub
- Exposed DSL JIT user upsert and protected GET /api/v1/me route
- Server auth integration tests for JWT rejection and user provisioning
affects: [phase-03-households, server-auth, principal-resolution, api-v1]
# Tech tracking
tech-stack:
added: [ktor-server-auth-jwt, jwks-rsa, hikari, testcontainers-postgresql]
patterns: [Ktor jwt("authentik") provider, cached/rate-limited JWKS provider, newSuspendedTransaction for route DB work, Postgres ON CONFLICT upsert]
key-files:
created:
- server/src/main/resources/db/migration/V1__users.sql
- 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
modified:
- server/src/main/resources/application.conf
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
- server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
key-decisions:
- "Pinned Exposed runtime is 0.55.0; the suspend transaction import used is org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction."
- "PrincipalResolver uses Postgres INSERT ... ON CONFLICT ... RETURNING via Exposed exec because the resolver must atomically upsert and return the generated user id."
- "CallLogging uses a custom method/path/status format and omits all headers because Ktor 3.4.1 server CallLogging has no redactHeader API."
patterns-established:
- "Protected server routes sit inside authenticate(\"authentik\") and resolve JWTPrincipal through PrincipalResolver before returning user data."
- "Server-side user identity is derived only from JWT claims, never request bodies."
- "Server auth tests use in-process RSA/JWKS support for JWT verifier coverage and Testcontainers Postgres for JIT provisioning coverage."
requirements-completed: [AUTH-03, AUTH-06]
# Metrics
duration: 13min
completed: 2026-04-28
---
# Phase 02 Plan 02: Server JWT Validation and JIT Users Summary
**Ktor Authentik JWT validation with cached JWKS, atomic Postgres user provisioning by OIDC sub, and protected `/api/v1/me`.**
## Performance
- **Duration:** 13 min for final executor verification and summary; task commits already existed on this branch when this executor resumed.
- **Started:** 2026-04-28T11:18:15Z
- **Completed:** 2026-04-28T11:31:08Z
- **Tasks:** 3 completed
- **Files modified:** 13 code/config/test files plus this summary
## Accomplishments
- Added JWT validation coverage for missing, expired, wrong-issuer, wrong-audience, blank-sub, and valid RS256 tokens.
- Installed Ktor `jwt("authentik")` with issuer/audience checks, 30-second max leeway, non-empty `sub`, cached JWKS, and rate limiting.
- Added `users` Flyway migration, Exposed table mapping, Hikari-backed Exposed connection, atomic JIT upsert by `sub`, and protected `/api/v1/me`.
- Added Testcontainers Postgres integration coverage proving first request creates a user row and later requests update mutable claims without duplication.
## Task Commits
Each task was committed atomically:
1. **Task 1: Create JWT validation tests before auth implementation** - `614b57c` (`test`)
2. **Task 2: Implement AuthConfig, JWT plugin, logging redaction, and verify Exposed suspend API** - `36c1b2c` (`feat`)
3. **Task 3: Add users migration, Exposed resolver, /api/v1/me route, and JIT tests** - `8cf112a` (`feat`)
No tracked file deletions were present in the task commits.
## Files Created/Modified
- `server/src/main/resources/application.conf` - Adds OIDC issuer/audience/JWKS/leeway config with env overrides.
- `server/src/main/resources/db/migration/V1__users.sql` - Creates the `users` table and `users_sub_idx`.
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` - Adds Hikari-backed Exposed connection after Flyway migration.
- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` - Installs safe CallLogging, authentication, DB migration/connection, and auth route wiring.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt` - Reads server OIDC config from HOCON.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` - Installs the Authentik JWT verifier.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` - Exposed DSL mapping for `users`.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt` - Resolves `JWTPrincipal` to `MeResponse` through atomic upsert.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt` - Provides protected `GET /api/v1/me`.
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt` - Generates RSA keys, JWKS provider, and configurable RS256 JWTs for tests.
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt` - Covers JWT validation positive and negative cases.
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` - Covers JIT provisioning against Testcontainers Postgres.
- `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` - Keeps `/health` test wiring compatible with authenticated route registration.
## Decisions Made
- Used `newSuspendedTransaction` from `org.jetbrains.exposed.sql.transactions.experimental` after confirming `org.jetbrains.exposed:exposed-jdbc:0.55.0`.
- Used raw SQL through Exposed `exec` for `INSERT ... ON CONFLICT ... RETURNING`, because the resolver needs the returned row and generated UUID in one atomic operation.
- Kept logging to method, path, and status only; no header logging or bearer-token redaction API is used.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Kept `/health` test route registration compatible with authenticated routes**
- **Found during:** Task 3
- **Issue:** Once `configureRouting()` registered `meRoute`, tests that installed routing without Authentication would fail route setup.
- **Fix:** Updated `ApplicationTest` to install the test JWT authentication plugin before calling `configureRouting()`.
- **Files modified:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt`
- **Verification:** `./gradlew :server:test`
- **Committed in:** `8cf112a`
**2. [Rule 3 - Blocking] Used Exposed `StatementType.SELECT` for Postgres upsert returning rows**
- **Found during:** Task 3
- **Issue:** `INSERT ... RETURNING` must be executed as a result-producing statement; otherwise Postgres reports that a result was returned when none was expected.
- **Fix:** Added `explicitStatementType = StatementType.SELECT` to the Exposed `exec` call.
- **Files modified:** `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt`
- **Verification:** `./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"`
- **Committed in:** `8cf112a`
---
**Total deviations:** 2 auto-fixed (1 bug, 1 blocking issue)
**Impact on plan:** Both fixes were required for the planned tests and route behavior. No extra feature scope was added.
## Issues Encountered
- Testcontainers Postgres made the first filtered test run take several minutes while the container image/runtime initialized. Subsequent server test runs completed from cache.
## Authentication Gates
None.
## Known Stubs
None.
## User Setup Required
None for this plan. Real Authentik provider setup remains covered by the Phase 2 setup documentation from plan `02-01`.
## Verification
- `./gradlew :server:dependencyInsight --dependency exposed-jdbc --configuration runtimeClasspath` - passed; Exposed JDBC version is `0.55.0`.
- `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` - passed.
- `./gradlew :server:test` - passed.
- Task acceptance greps for OIDC config, JWT verifier settings, logging safety, migration shape, no DAO imports, no blocking `transaction {}` in auth code, `/api/v1/me`, Testcontainers, `postgres:16`, and Flyway all passed.
## Next Phase Readiness
Phase 3 can extend `PrincipalResolver` from user identity to household-scoped principal resolution. The server now has the stable `users.sub` anchor and `/api/v1/me` boundary that Phase 3 onboarding and household membership can build on.
## Self-Check: PASSED
- Created/modified key files exist.
- Task commits found: `614b57c`, `36c1b2c`, `8cf112a`.
- Required verification commands passed.
- No unplanned tracked file deletions were detected in task commits.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,177 @@
---
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/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
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt
autonomous: true
requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
must_haves:
truths:
- "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/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/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"
---
<objective>
Define the common OIDC and AuthState storage contracts, plus JVM/Wasm actuals that keep secondary targets compiling.
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.
</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
@AGENTS.md
@composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt
@composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Define common OIDC and secure store contracts</name>
<read_first>
- 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 and secure storage recommendation)
</read_first>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt</files>
<behavior>
- `OidcResult.Success` carries `authStateJson`, `accessToken`, nullable `idToken`, and `expiresAtEpochMillis`.
- `OidcClient` exposes suspend `login()`, `refresh(authStateJson)`, and `logout(authStateJson)`.
- Common contract text states native actuals use AppAuth and request exactly `openid profile email offline_access` per D-01/D-06.
- `SecureAuthStateStore` exposes `read()`, `write(authStateJson)`, and `clear()`.
- Contract test proves write overwrites previous value, read returns latest value, and clear removes it.
</behavior>
<action>
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 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()`. The KDoc must state it persists the full AppAuth AuthState JSON blob per D-13 and must not use no-arg insecure settings for tokens.
Add `SecureAuthStateStoreContractTest` using a fake in-memory implementation in commonTest to lock the store behavior. Keep this test platform-free; Android and iOS secure implementations are created in Plans 02-04 and 02-05.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Common auth seams exist with exact scope/logout/storage semantics and testable store behavior.</done>
</task>
<task type="auto">
<name>Task 2: Add JVM and Wasm actuals</name>
<read_first>
- .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
</read_first>
<files>composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt, composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt</files>
<action>
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()`.
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.
</action>
<verify>
<automated>./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs</automated>
</verify>
<acceptance_criteria>
- `grep -q 'DEV_AUTH_TOKEN' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt`
- `grep -q 'actual class SecureAuthStateStore' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt`
- `grep -q 'NotImplementedError("Wasm OIDC: v2")' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt`
- `grep -q 'actual class SecureAuthStateStore' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt`
- `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0
</acceptance_criteria>
<done>Secondary targets compile without expanding Phase 2 into real Desktop or Wasm OIDC.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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 | 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 |
</threat_model>
<verification>
Run `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs`.
</verification>
<success_criteria>
Common OIDC/storage contracts exist below the file-count threshold, JVM/Wasm targets compile, and downstream Android/iOS/AuthSession plans can depend on stable auth seams.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-03-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,159 @@
---
phase: 02-authentication-foundation
plan: 03
subsystem: auth
tags: [oidc, appauth, kmp, wasm, jvm, authstate]
requires:
- phase: 02-authentication-foundation
provides: 02-01 shared OIDC constants and Phase 2 client dependencies
provides:
- Common `OidcClient` expect seam with suspend login, refresh, and logout
- Common `OidcResult` model for AuthSession and LoginViewModel consumers
- Common `SecureAuthStateStore` expect contract for opaque AppAuth AuthState JSON
- JVM dev-only `DEV_AUTH_TOKEN` OIDC actual and in-memory AuthState store actual
- Wasm v2 OIDC stubs and in-memory AuthState store actual
- SecureAuthStateStore common contract tests for write, overwrite, read, and clear
affects: [02-04-android-auth-actuals, 02-05-ios-auth-actuals, 02-06-auth-session-ui]
tech-stack:
added: []
patterns:
- "OIDC seam pattern: common expects pin AppAuth/scopes/logout semantics while target actuals own platform mechanics."
- "Secondary target pattern: JVM uses explicit DEV_AUTH_TOKEN dev behavior; Wasm throws the documented v2 NotImplementedError boundary."
key-files:
created:
- 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/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
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt
modified: []
key-decisions:
- "JVM actuals were added with Task 1 because the required `:composeApp:jvmTest` acceptance gate cannot compile common expect classes without JVM actual declarations."
- "Kotlin expect/actual beta diagnostics are suppressed at the auth seam file level to satisfy the existing `-Werror` build without changing Gradle configuration."
- "Wasm OIDC remains an explicit v2 boundary by throwing `NotImplementedError(\"Wasm OIDC: v2\")` from login, refresh, and logout."
patterns-established:
- "AuthState JSON is treated as opaque common data; secure mobile storage actuals remain owned by Android/iOS plans."
- "Desktop auth is dev-only and requires an externally supplied `DEV_AUTH_TOKEN`; no usable bearer token is hardcoded."
requirements-completed: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
duration: 31m
completed: 2026-04-28
---
# Phase 02 Plan 03: Common OIDC and AuthState Store Contracts Summary
**Stable KMP auth seams for AppAuth-backed mobile login, explicit JVM dev-token behavior, Wasm v2 stubs, and contract-tested AuthState JSON storage semantics.**
## Performance
- **Duration:** 31 min
- **Started:** 2026-04-28T11:18:45Z
- **Completed:** 2026-04-28T11:49:40Z
- **Tasks:** 2
- **Files modified:** 8
## Accomplishments
- Added common auth contracts: `OidcClient`, `OidcResult`, and `SecureAuthStateStore`.
- Pinned native OIDC behavior in common KDoc: AppAuth, `suspendCancellableCoroutine`, exact `openid profile email offline_access` scopes, fresh-token refresh, and RP-initiated logout.
- Added JVM actuals for desktop/dev test compilation with explicit `DEV_AUTH_TOKEN` behavior and no hardcoded bearer token.
- Added Wasm actuals that preserve the documented v2 OIDC boundary while keeping `compileKotlinWasmJs` green.
- Added common contract tests proving store write overwrite, latest read, and clear semantics.
## Task Commits
1. **Task 1 RED: SecureAuthStateStore contract test** - `7ef222e` (test)
2. **Task 1 GREEN: Common auth contracts plus JVM actuals** - `edc2a1d` (feat)
3. **Task 2: Wasm auth stubs** - `0dbd374` (feat)
_Note: Task 1 was TDD and produced RED + GREEN commits. No refactor commit was needed._
## Files Created/Modified
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` - Common expect OIDC client seam with pinned AppAuth/scopes/refresh/logout semantics.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt` - Sealed result model for success, cancellation, network failure, and auth failure.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt` - Common expect secure store contract for opaque AppAuth AuthState JSON.
- `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt` - Desktop dev actual using `DEV_AUTH_TOKEN`.
- `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt` - In-memory desktop AuthState store actual.
- `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` - Wasm v2 OIDC boundary stubs.
- `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.wasmJs.kt` - In-memory Wasm AuthState store actual.
- `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt` - Store read/write/overwrite/clear contract tests.
## Decisions Made
See frontmatter `key-decisions`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Added JVM actuals during Task 1 GREEN**
- **Found during:** Task 1 verification
- **Issue:** The plan required `./gradlew :composeApp:jvmTest` to pass after adding common `expect class` declarations, but JVM compilation requires matching JVM `actual` declarations.
- **Fix:** Added the JVM dev `OidcClient` actual and in-memory `SecureAuthStateStore` actual in the Task 1 GREEN commit. Task 2 then added the Wasm actuals as planned.
- **Files modified:** `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt`, `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.jvm.kt`
- **Verification:** `./gradlew :composeApp:jvmTest`
- **Committed in:** `edc2a1d`
**2. [Rule 3 - Blocking] Suppressed expect/actual beta diagnostics at file level**
- **Found during:** Task 1 verification
- **Issue:** Kotlin emitted expect/actual beta warnings and the project treats warnings as errors.
- **Fix:** Added targeted `@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")` to the auth expect/actual files.
- **Files modified:** `OidcClient.kt`, `SecureAuthStateStore.kt`, JVM actual files, Wasm actual files
- **Verification:** `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs`
- **Committed in:** `edc2a1d`, `0dbd374`
---
**Total deviations:** 2 auto-fixed (2 x Rule 3).
**Impact on plan:** No behavior scope changed. The deviations only made the required verification gates compatible with Kotlin expect/actual compilation under the project build settings.
## Known Stubs
| File | Line | Reason |
|------|------|--------|
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | 7 | Intentional v2 boundary per D-03 and plan acceptance criteria. |
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | 11 | Intentional v2 boundary per D-03 and plan acceptance criteria. |
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | 15 | Intentional v2 boundary per D-03 and plan acceptance criteria. |
## Issues Encountered
- Concurrent Wave 2 work landed `02-02` commits while this plan was executing. No conflicts touched this plan's owned files.
- `gsd-sdk query init.execute-phase 02` updated `.planning/STATE.md` at startup before task work began. Final state updates are handled in the metadata step.
## User Setup Required
None.
## Verification
- `./gradlew :composeApp:jvmTest` - PASS
- `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` - PASS
- Task 1 acceptance greps - PASS
- Task 2 acceptance greps - PASS
## Next Phase Readiness
Android and iOS auth actual plans can now implement AppAuth behind stable common seams. AuthSession/UI plans can consume `OidcResult` and `SecureAuthStateStore` without platform-specific APIs.
## Self-Check: PASSED
- Created files exist: all 8 plan-owned source/test files plus this summary were found.
- Commits exist: `7ef222e`, `edc2a1d`, and `0dbd374` were found in git history.
- Acceptance criteria: all required grep checks passed.
- Plan-level verification: `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` passed.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,161 @@
---
phase: 02-authentication-foundation
plan: 04
type: execute
wave: 3
depends_on: [02-01, 02-03]
files_modified:
- 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:
- "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/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/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"
---
<objective>
Implement the Android OIDC and secure storage actuals.
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.
</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-03-SUMMARY.md
@AGENTS.md
@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt
@composeApp/src/androidMain/AndroidManifest.xml
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement Android AppAuth OidcClient actual</name>
<read_first>
- 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
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-05, D-06, D-09, D-16, D-19, D-20)
</read_first>
<files>composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt</files>
<action>
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.
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.
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.
</action>
<verify>
<automated>./gradlew :composeApp:compileDebugKotlinAndroid</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Android AppAuth login, refresh, and logout compile behind the common OidcClient contract.</done>
</task>
<task type="auto">
<name>Task 2: Implement Android secure AuthState store and callback manifest</name>
<read_first>
- 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)
</read_first>
<files>composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt, composeApp/src/androidMain/AndroidManifest.xml</files>
<action>
Implement Android `actual class SecureAuthStateStore` using AndroidX Security Crypto `EncryptedSharedPreferences`. Store one opaque AuthState JSON string per app install under a private key. Add a short code comment noting AndroidX Security Crypto deprecation is contained behind this abstraction because AUTH-02 explicitly calls for Android EncryptedSharedPreferences in v1.
Do not use no-arg `Settings()`, ordinary `SharedPreferences`, or plaintext file storage for auth tokens.
Register AppAuth redirect handling in `composeApp/src/androidMain/AndroidManifest.xml` with `net.openid.appauth.RedirectUriReceiverActivity` and an intent filter for scheme `recipe` and host `callback`, matching D-09 exactly (`recipe://callback`).
</action>
<verify>
<automated>./gradlew :composeApp:compileDebugKotlinAndroid</automated>
</verify>
<acceptance_criteria>
- `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
- `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth`
- `! grep -R 'getSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
- `grep -q 'RedirectUriReceiverActivity' composeApp/src/androidMain/AndroidManifest.xml`
- `grep -q 'android:scheme="recipe"' composeApp/src/androidMain/AndroidManifest.xml`
- `grep -q 'android:host="callback"' composeApp/src/androidMain/AndroidManifest.xml`
- `./gradlew :composeApp:compileDebugKotlinAndroid` exits 0
</acceptance_criteria>
<done>Android token storage is explicit and the custom URL callback is registered for AppAuth.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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 | 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 |
</threat_model>
<verification>
Run `./gradlew :composeApp:compileDebugKotlinAndroid`.
</verification>
<success_criteria>
Android AppAuth login/refresh/logout and Android secure AuthState persistence compile independently below the file-count threshold.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-04-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,159 @@
---
phase: 02-authentication-foundation
plan: 04
subsystem: auth
tags: [android, oidc, appauth, encryptedsharedpreferences, authstate]
requires:
- phase: 02-authentication-foundation
provides: 02-01 Phase 2 Android AppAuth/Security Crypto dependencies and OIDC constants
- phase: 02-authentication-foundation
provides: 02-03 common OidcClient, OidcResult, and SecureAuthStateStore expect contracts
provides:
- Android AppAuth authorization-code + PKCE login through the system browser
- Android AppAuth AuthState JSON serialization for login and fresh-token refresh
- Android RP-initiated logout through AppAuth EndSessionRequest when discovery metadata exposes end-session
- Android EncryptedSharedPreferences-backed SecureAuthStateStore for opaque AuthState JSON
- Android manifest registration for recipe://callback via RedirectUriReceiverActivity
affects: [02-06-auth-session-ui, 02-07-auth-integration-verification]
tech-stack:
added: []
patterns:
- "Android OIDC actual resolves Context from Koin's Android context while preserving the no-arg common expect constructor."
- "AppAuth callback bridge uses private dynamic broadcast PendingIntents and suspendCancellableCoroutine."
- "AuthState JSON remains opaque; storage and refresh paths never log token-bearing values."
key-files:
created:
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt
- composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt
modified:
- composeApp/src/androidMain/AndroidManifest.xml
key-decisions:
- "Use Koin's registered Android Context from the no-arg Android actuals instead of changing common constructor contracts from 02-03."
- "Task 1 included the Android SecureAuthStateStore actual because Android target compilation cannot pass with only one of the auth expect actuals present."
- "Treat missing access tokens from token exchange/refresh as AuthError, not Success with an empty token."
patterns-established:
- "Android AppAuth login/request scopes are pinned exactly to openid profile email offline_access."
- "Android token persistence is contained behind SecureAuthStateStore so the deprecated AndroidX Security Crypto implementation can be replaced later without touching AuthSession."
requirements-completed: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
duration: 8 min
completed: 2026-04-28
---
# Phase 02 Plan 04: Android OIDC Actuals Summary
**Android AppAuth login, refresh, logout, and encrypted AuthState persistence wired behind the Phase 2 common auth contracts.**
## Performance
- **Duration:** 8 min
- **Started:** 2026-04-28T13:52:41Z
- **Completed:** 2026-04-28T14:00:47Z
- **Tasks:** 2
- **Files modified:** 3
## Accomplishments
- Added Android `OidcClient` actual using AppAuth discovery, authorization-code flow, exact `openid profile email offline_access` scopes, token exchange, `AuthState.jsonSerializeString()`, `AuthState.jsonDeserialize(...)`, and `performActionWithFreshTokens`.
- Added Android `SecureAuthStateStore` actual backed by `EncryptedSharedPreferences`.
- Registered `net.openid.appauth.RedirectUriReceiverActivity` for `recipe://callback` in the Android manifest.
- Kept auth diagnostics token-safe: no logging of AuthState JSON, access tokens, refresh tokens, ID tokens, bearer headers, or authorization headers.
## Task Commits
1. **Task 1: Implement Android AppAuth OidcClient actual** - `fa78ee3` (feat)
2. **Task 2: Implement Android secure AuthState store and callback manifest** - `6385453` (feat)
3. **Rule 1 fix: Harden Android OIDC token result mapping** - `11a5eeb` (fix)
## Files Created/Modified
- `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - Android AppAuth actual for login, refresh, logout, AuthState JSON serialization/deserialization, and OidcResult mapping.
- `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt` - Android encrypted storage actual for one opaque AuthState JSON blob per install.
- `composeApp/src/androidMain/AndroidManifest.xml` - Explicit AppAuth redirect receiver registration for `recipe://callback`.
## Decisions Made
See frontmatter `key-decisions`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Added Android SecureAuthStateStore actual during Task 1**
- **Found during:** Task 1 verification
- **Issue:** `./gradlew :composeApp:compileDebugKotlinAndroid` failed because `SecureAuthStateStore` had a common `expect` declaration but no Android `actual`. The plan listed the store in Task 2, but the Android target cannot compile any auth expect declarations until both Android actuals exist.
- **Fix:** Implemented `SecureAuthStateStore.android.kt` with `EncryptedSharedPreferences` during Task 1 so the required Task 1 Android compile gate could pass.
- **Files modified:** `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt`
- **Verification:** `./gradlew :composeApp:compileDebugKotlinAndroid`
- **Committed in:** `fa78ee3`
**2. [Rule 1 - Bug] Hardened token result mapping**
- **Found during:** Final correctness pass
- **Issue:** The initial token exchange path could report success if AppAuth returned a `TokenResponse` with a missing access token.
- **Fix:** Added explicit missing-token guards and preserved AppAuth discovery exceptions so network failures and auth failures map cleanly.
- **Files modified:** `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt`
- **Verification:** `./gradlew :composeApp:compileDebugKotlinAndroid`; all Task 1 and Task 2 grep gates re-run.
- **Committed in:** `11a5eeb`
---
**Total deviations:** 2 auto-fixed (1 x Rule 3 blocking, 1 x Rule 1 bug).
**Impact on plan:** No scope expansion beyond Android auth ownership. The Rule 3 change only corrected task ordering required by Kotlin expect/actual compilation.
## Known Stubs
None.
## Threat Flags
None - all new trust-boundary surfaces were already listed in the plan threat model.
## Issues Encountered
- Concurrent iOS plan work appeared as untracked `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/` and `iosApp/Podfile`. These files were not read, staged, modified, or committed by this plan.
- Pre-existing untracked `.claude/` and `AGENTS.md` were left untouched.
- STATE.md/ROADMAP.md updates were intentionally not performed by this spawned Android executor because the user constrained writes to Android-owned files plus this summary; central planning state remains orchestrator-owned.
## User Setup Required
None.
## Verification
- `./gradlew :composeApp:compileDebugKotlinAndroid` - PASS
- `grep -q 'AuthorizationServiceConfiguration.fetchFromIssuer' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'setScopes("openid", "profile", "email", "offline_access")' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'suspendCancellableCoroutine' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'performActionWithFreshTokens' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'EndSessionRequest' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` - PASS
- `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt` - PASS
- `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth` - PASS
- `! grep -R 'getSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt` - PASS
- `grep -q 'RedirectUriReceiverActivity' composeApp/src/androidMain/AndroidManifest.xml` - PASS
- `grep -q 'android:scheme="recipe"' composeApp/src/androidMain/AndroidManifest.xml` - PASS
- `grep -q 'android:host="callback"' composeApp/src/androidMain/AndroidManifest.xml` - PASS
- Token/log scan for `Logger`, `println`, `printStackTrace`, `Authorization:`, and `Bearer` under Android auth files - PASS
## Next Phase Readiness
Android auth actuals now compile behind the common contracts. AuthSession/UI integration in 02-06 can call login, refresh, logout, and the secure store without Android-specific APIs.
## Self-Check: PASSED
- Created files exist: `OidcClient.android.kt`, `SecureAuthStateStore.android.kt`, and this summary were found.
- Modified files exist: `AndroidManifest.xml` contains `RedirectUriReceiverActivity`, `android:scheme="recipe"`, and `android:host="callback"`.
- Commits exist: `fa78ee3`, `6385453`, and `11a5eeb` were found in git history.
- Acceptance criteria: all required grep checks passed.
- Plan-level verification: `./gradlew :composeApp:compileDebugKotlinAndroid` passed.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

@@ -0,0 +1,169 @@
---
phase: 02-authentication-foundation
plan: 05
type: execute
wave: 3
depends_on: [02-01, 02-03]
files_modified:
- 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:
- "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/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: "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"
---
<objective>
Implement the iOS OIDC and secure storage actuals.
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.
</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-03-SUMMARY.md
@AGENTS.md
@iosApp/iosApp/Info.plist
@iosApp/iosApp/iOSApp.swift
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement iOS AppAuth OidcClient actual and CocoaPods bridge</name>
<read_first>
- 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)
</read_first>
<files>composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, iosApp/Podfile</files>
<action>
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.
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.
Ensure AppAuth CocoaPod integration is present through the existing Gradle CocoaPods setup from Plan 02-01 and/or `iosApp/Podfile` as required by the repo's KMP CocoaPods wiring. Do not introduce an additional OIDC library.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>iOS AppAuth login, refresh, and logout compile behind the common OidcClient contract.</done>
</task>
<task type="auto">
<name>Task 2: Implement iOS Keychain store and callback wiring</name>
<read_first>
- 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)
</read_first>
<files>composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift</files>
<action>
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.
Add `CFBundleURLTypes` to `iosApp/iosApp/Info.plist` registering scheme `recipe`, matching redirect URI `recipe://callback`.
Add SwiftUI `.onOpenURL` or an app delegate bridge in `iOSApp.swift` that forwards incoming `recipe://callback` URLs to the current AppAuth external user-agent session held by the KMP iOS OidcClient bridge. Keep existing Koin initialization intact.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>iOS token storage is explicit and the custom URL callback is wired back into AppAuth.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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 | 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 |
</threat_model>
<verification>
Run `./gradlew :composeApp:compileKotlinIosSimulatorArm64`.
</verification>
<success_criteria>
iOS AppAuth login/refresh/logout, iOS Keychain AuthState persistence, URL scheme registration, and callback forwarding compile independently below the file-count threshold.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-05-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,153 @@
---
phase: 02-authentication-foundation
plan: 05
subsystem: auth
tags: [oidc, appauth, ios, keychain, swiftui, callback]
requires:
- phase: 02-authentication-foundation
provides: 02-01 CocoaPods/AppAuth dependency wiring and OIDC constants
- phase: 02-authentication-foundation
provides: 02-03 common OidcClient and SecureAuthStateStore contracts
provides:
- iOS AppAuth OidcClient actual with login, refresh, logout, and callback bridge
- iOS Keychain-backed SecureAuthStateStore using kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
- recipe URL scheme registration in Info.plist
- SwiftUI onOpenURL callback forwarding into the current AppAuth flow
- iosApp Podfile with AppAuth pod integration
affects: [02-06-auth-session-ui, 02-07-auth-integration-verification]
tech-stack:
added:
- AppAuth CocoaPod reference in iosApp/Podfile
patterns:
- "iOS AppAuth bridge: Kotlin singleton holds currentAuthorizationFlow; SwiftUI forwards recipe://callback URLs by absolute string."
- "iOS AuthState persistence: full OIDAuthState NSSecureCoding archive wrapped in an opaque JSON string and stored through KeychainSettings."
key-files:
created:
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt
- composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt
- iosApp/Podfile
modified:
- iosApp/iosApp/Info.plist
- iosApp/iosApp/iOSApp.swift
key-decisions:
- "AppAuth-iOS AuthState persistence uses NSSecureCoding wrapped in JSON because AppAuth-iOS 2.0.0 does not expose the Android-style serialize()/jsonDeserialize API."
- "SecureAuthStateStore was implemented in the first task commit because the Task 1 compile gate cannot pass while the common expect class lacks an iOS actual."
- "SwiftUI forwards only recipe://callback URLs to the KMP bridge; other URLs are ignored before AppAuth sees them."
patterns-established:
- "Never log token-bearing values in iOS auth actuals; token variables are only returned through OidcResult or stored in Keychain."
- "Mobile callback state remains inside AppAuth's current external user-agent session and is consumed once."
requirements-completed: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
duration: 27m
completed: 2026-04-28
---
# Phase 02 Plan 05: iOS AppAuth Actuals Summary
**iOS AppAuth login, fresh-token refresh, RP-initiated logout, Keychain AuthState persistence, and recipe://callback forwarding behind the Phase 02 common auth contracts.**
## Performance
- **Duration:** 27 min
- **Started:** 2026-04-28T13:52:54Z
- **Completed:** 2026-04-28T14:19:03Z
- **Tasks:** 2
- **Files modified:** 5
## Accomplishments
- Added the iOS `OidcClient` actual using AppAuth discovery, authorization-code flow with PKCE, exact `openid profile email offline_access` scopes, fresh-token refresh, and end-session logout.
- Added the iOS secure store actual using Keychain-backed settings with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.
- Registered the `recipe` URL scheme and wired SwiftUI `.onOpenURL` to forward only `recipe://callback` URLs to the active AppAuth external user-agent session.
- Added `iosApp/Podfile` with `AppAuth` so the iOS shell has explicit pod integration alongside the existing KMP CocoaPods block.
## Task Commits
1. **Task 1: Implement iOS AppAuth OidcClient actual and CocoaPods bridge** - `ac9fc61` (feat)
2. **Task 2: Implement iOS Keychain store and callback wiring** - `88dc8d7` (feat)
## Files Created/Modified
- `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` - AppAuth-iOS login, refresh, logout, AuthState archive/restore, and callback bridge.
- `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt` - Keychain-backed opaque AuthState store with the required accessibility class.
- `iosApp/Podfile` - iOS target Podfile declaring the `AppAuth` pod.
- `iosApp/iosApp/Info.plist` - `CFBundleURLTypes` registration for the `recipe` custom URL scheme.
- `iosApp/iosApp/iOSApp.swift` - SwiftUI `.onOpenURL` forwarding for `recipe://callback`.
## Decisions Made
See frontmatter `key-decisions`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Implemented `SecureAuthStateStore.ios.kt` during Task 1**
- **Found during:** Task 1 verification
- **Issue:** `./gradlew :composeApp:compileKotlinIosSimulatorArm64` cannot pass after adding only `OidcClient.ios.kt` because the common `SecureAuthStateStore` expect class also requires an iOS actual.
- **Fix:** Added the Keychain-backed iOS secure store in the Task 1 commit, then Task 2 added the URL scheme and Swift callback wiring.
- **Files modified:** `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt`
- **Verification:** `./gradlew :composeApp:compileKotlinIosSimulatorArm64`
- **Committed in:** `ac9fc61`
**2. [Rule 3 - Blocking] Wrapped AppAuth-iOS secure archive in JSON**
- **Found during:** Task 1 implementation
- **Issue:** AppAuth-iOS 2.0.0 exposes `OIDAuthState` as `NSSecureCoding`; it does not expose the Android-style `serialize()` / JSON-deserialize API assumed by the plan.
- **Fix:** Persisted a JSON wrapper containing the full `NSKeyedArchiver` secure archive of `OIDAuthState`, preserving the common opaque `authStateJson` contract while using AppAuth-iOS' supported persistence mechanism.
- **Files modified:** `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt`
- **Verification:** `./gradlew :composeApp:compileKotlinIosSimulatorArm64`
- **Committed in:** `ac9fc61`
---
**Total deviations:** 2 auto-fixed (2 x Rule 3).
**Impact on plan:** No auth behavior was reduced. Both fixes were required for the iOS target to compile against the actual AppAuth-iOS API and the existing common expect contracts.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model. This plan intentionally touches the browser callback, Keychain storage, and Swift-to-KMP callback trust boundaries already listed in `02-05-PLAN.md`.
## Issues Encountered
- `iosApp/Podfile` did not exist even though the plan listed it in `read_first`; it was created in Task 1.
- A parallel `git add` attempt briefly hit Git's index lock. Staging was retried sequentially; no repository state was lost.
- `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` passed as an extra confidence check and confirmed `IosAppAuthBridge.shared.resumeExternalUserAgentFlow(urlString:)` is exported to Swift.
## User Setup Required
None for this plan. Real login still requires the Authentik provider configuration documented in `docs/authentik-setup.md`.
## Verification
- Task 1 acceptance greps - PASS
- Task 2 acceptance greps - PASS
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64` - PASS
- Extra: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` - PASS
- Token/logging scan - PASS; no `Logger`, `println`, or token/AuthState logging calls were added.
## Next Phase Readiness
Plan 02-06 can consume the common `OidcClient` and `SecureAuthStateStore` on iOS. Plan 02-07 should still run real iOS/Authenik UAT for browser handoff, refresh across relaunch, and end-session behavior.
## Self-Check: PASSED
- Created/modified files exist: all five plan-owned source/config files plus this summary were found.
- Commits exist: `ac9fc61` and `88dc8d7` were found in git history.
- Acceptance criteria: all Task 1 and Task 2 grep checks passed.
- Plan-level verification: `./gradlew :composeApp:compileKotlinIosSimulatorArm64` passed.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

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

View File

@@ -0,0 +1,164 @@
---
phase: 02-authentication-foundation
plan: 06
subsystem: auth
tags: [kmp, auth-session, ktor-client, bearer-auth, koin, oidc]
requires:
- phase: 02-authentication-foundation
provides: 02-01 shared Constants, User, MeResponse, Ktor client dependencies
- phase: 02-authentication-foundation
provides: 02-03 common OidcClient and SecureAuthStateStore contracts
- phase: 02-authentication-foundation
provides: 02-04 Android AppAuth and secure store actuals
- phase: 02-authentication-foundation
provides: 02-05 iOS AppAuth and Keychain actuals
provides:
- AuthState Loading / Unauthenticated / Authenticated(user, householdId?) model
- AuthSession StateFlow owner for restore, login, logout, proactive refresh, and Ktor reactive refresh
- MeClient for GET /api/v1/me mapped to User
- AuthHttpClient Ktor bearer client with token-redacting logging
- authModule Koin singleton wiring included from appModule
affects: [02-07-auth-integration-verification, phase-03-households]
tech-stack:
added: []
patterns:
- "AuthSession depends on small common gateways so state-machine tests use fakes while production constructors delegate to platform expect classes."
- "Authenticated state is built from server MeResponse only; Phase 2 householdId remains null."
- "Ktor bearer loadTokens/refreshTokens delegates to AuthSession, with Authorization header sanitization and message redaction."
key-files:
created:
- 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/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
key-decisions:
- "Use lightweight common gateway interfaces for AuthSession tests instead of changing OidcClient/SecureAuthStateStore expect/actual contracts."
- "MeClient accepts an optional access token for AuthSession's explicit /me calls; other authenticated clients use AuthHttpClient bearer auth."
- "Koin provides AuthSession and AuthHttpClient as singletons from authModule; Koin startup remains platform bootstrap-owned."
patterns-established:
- "AuthSession.restore/login refreshes through OidcClient before /api/v1/me and persists the updated opaque AuthState JSON."
- "Refresh failures, including invalid_grant/AuthError, silently clear the store and emit Unauthenticated."
- "logout() attempts end-session first, then always clears the secure store and emits Unauthenticated."
requirements-completed: [AUTH-01, AUTH-02, AUTH-04, AUTH-05]
duration: 34m
completed: 2026-04-28
---
# Phase 02 Plan 06: Common Auth Runtime Summary
**AuthSession state machine, token-safe Ktor bearer client, /api/v1/me client, and Koin singleton wiring for persisted OIDC sessions.**
## Performance
- **Duration:** 34 min
- **Started:** 2026-04-28T14:22:01Z
- **Completed:** 2026-04-28T14:56:05Z
- **Tasks:** 3
- **Files modified:** 7
## Accomplishments
- Added common AuthSession behavior for Loading -> restored Authenticated/Unauthenticated, login, logout, proactive refresh, and Ktor reactive refresh support.
- Added AuthState with Phase 3-ready `householdId: HouseholdId? = null`, with tests asserting Phase 2 authenticated sessions keep it null.
- Added MeClient for `GET /api/v1/me`, mapping server MeResponse to User so authenticated state is built from the server, not token claims.
- Added AuthHttpClient with Ktor bearer `loadTokens`, `refreshTokens`, `sendWithoutRequest`, ContentNegotiation JSON, and token-redacting logging.
- Wired authModule into appModule as Koin singletons without changing Koin startup ownership.
## Task Commits
1. **Task 1: Write AuthSession state-machine tests** - `06e5eaf` (test)
2. **Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client** - `0a24be9` (feat)
3. **Task 3: Wire authModule into Koin** - `938f324` (feat)
## Files Created/Modified
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt` - Loading/Unauthenticated/Authenticated auth model with nullable household id.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt` - StateFlow auth owner with restore/login/logout/token refresh behavior and testable gateway seams.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` - Ktor client factory with bearer auth refresh and token redaction.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt` - `/api/v1/me` client mapped to shared User.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt` - Koin singleton definitions for auth runtime collaborators.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` - Includes authModule from the app bootstrap module.
- `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` - State-machine tests for restore, login, invalid_grant/AuthError, logout, and cancellation.
## Decisions Made
- Kept platform OidcClient and SecureAuthStateStore expect/actual contracts unchanged; AuthSession uses gateway interfaces internally so common tests can fake dependencies.
- Used explicit token passing only for AuthSession's `/me` call. Broader authenticated API access goes through AuthHttpClient and its bearer plugin.
- No auth UI ViewModels were registered because they do not exist yet in this plan's input set.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Added testable gateway seams for AuthSession dependencies**
- **Found during:** Task 1/2 (state-machine tests and implementation)
- **Issue:** The plan required fakes for OidcClient and SecureAuthStateStore, but the existing common contracts are concrete expect classes. Changing expect/actual signatures would have touched platform files outside this plan's write scope.
- **Fix:** Added small common interfaces (`OidcClientGateway`, `AuthStateStore`, `MeGateway`) and made AuthSession's production constructor delegate concrete platform classes through adapters.
- **Files modified:** `AuthSession.kt`, `MeClient.kt`, `AuthSessionTest.kt`
- **Verification:** `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` and full plan gate passed.
- **Committed in:** `0a24be9`
**2. [Rule 3 - Blocking] Added explicit Koin generic types**
- **Found during:** Task 3 verification
- **Issue:** Koin's `single { ... }` calls could not infer expect-class singleton types under the KMP compile targets.
- **Fix:** Changed definitions to `single<SecureAuthStateStore>`, `single<OidcClient>`, `single<MeClient>`, and `single<AuthSession>`, with typed `get<...>()` calls.
- **Files modified:** `AuthModule.kt`
- **Verification:** `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`
- **Committed in:** `938f324`
---
**Total deviations:** 2 auto-fixed (2 x Rule 3).
**Impact on plan:** No scope expansion beyond common auth runtime and DI wiring. Both fixes were required to satisfy the planned tests and cross-target compile gate while respecting the write scope.
## Issues Encountered
- The RED test commit was amended before GREEN to make JUnit test methods return void while still failing on missing production auth runtime. This preserved the TDD red gate without adding a separate formatting-only commit.
- Pre-existing untracked `.claude/` and `AGENTS.md` remain untouched.
## Known Stubs
None.
## Threat Flags
None beyond the plan's threat model. The new network client, bearer refresh, secure-store access, and AuthSession UI state surfaces were all covered by T-02-06-01 through T-02-06-05.
## User Setup Required
None for this plan. Real OIDC login still requires the Authentik provider setup documented in `docs/authentik-setup.md`.
## Verification
- `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` - PASS
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` - PASS
- Task acceptance greps for `invalid_grant|AuthError`, `householdId`, `StateFlow<AuthState>`, `refreshTokens`, `sendWithoutRequest`, no `Authorization.*$`, `val authModule`, and appModule `authModule` - PASS
- Token/logging scan - PASS; no bearer token values or AuthState JSON are logged.
## Next Phase Readiness
Plan 02-07 can run integration verification against the common AuthSession + platform AppAuth actuals. Phase 3 can extend `/api/v1/me` with household data and fill `AuthState.Authenticated.householdId` without changing the sealed auth state shape.
## Self-Check: PASSED
- Created/modified files exist: all seven plan-owned source/test files plus this summary were found.
- Commits exist: `06e5eaf`, `0a24be9`, and `938f324` were found in git history.
- Acceptance criteria: all task grep checks passed.
- Plan-level verification: `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` passed.
---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*

View File

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

View File

@@ -0,0 +1,189 @@
---
phase: 02-authentication-foundation
plan: 07
subsystem: auth
tags: [kmp, compose-multiplatform, material3, koin-viewmodel, compose-resources, auth-gate]
requires:
- phase: 02-authentication-foundation
provides: 02-06 AuthSession StateFlow, AuthState model, authModule Koin singletons
provides:
- SplashScreen / LoginScreen / PostLoginPlaceholderScreen Phase 2 auth gate
- LoginViewModel + LoginScreenState + PostLoginViewModel mapping AuthSession results to Compose Resources
- Compose Resources strings for the seven Phase 2 auth keys
- RecipeTheme Material 3 light/dark seed with primary `#3B6939` / `#A2D597`
affects: [phase-03-households]
tech-stack:
added:
- kotlinx-coroutines-test (commonTest only) for the multiplatform `runTest` runtime
patterns:
- "App.kt observes AuthSession.state via collectAsStateWithLifecycle and renders one of three screens; no manual navigation."
- "LoginViewModel.onSignInClick() returns the launched Job so commonTest can join() deterministically without dragging in a TestDispatcher."
- "ViewModels registered in authModule via org.koin.core.module.dsl.viewModel; consumed via koinViewModel<T>()."
- "All commonTest coroutine tests use kotlinx.coroutines.test.runTest so wasmJs can compile (runBlocking is JVM/Native-only)."
key-files:
created:
- composeApp/src/commonMain/composeResources/values/strings.xml
- 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
- .planning/phases/02-authentication-foundation/deferred-items.md
modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt
- composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt
- composeApp/build.gradle.kts
- gradle/libs.versions.toml
key-decisions:
- "ViewModels registered in authModule (alongside AuthSession) instead of a new uiModule — keeps the single Koin module that owns AuthSession also owning its UI consumers."
- "LoginViewModel.onSignInClick() returns Job rather than swallowing it so tests deterministically join without a TestDispatcher; production callers ignore the returned Job."
- "AuthSession.initialize() is launched from a LaunchedEffect in App.kt rather than a Koin lifecycle hook; keeps Phase 2 startup explicit and easy to trace."
- "Pre-existing ./gradlew check failures (Android JVM SecureAuthStateStoreContractTest, ios SecureAuthStateStore ktlint) are out of scope for 02-07 and tracked in deferred-items.md per scope-boundary rule."
requirements-completed: [AUTH-01, AUTH-04, AUTH-05]
duration: 10m
completed: 2026-04-28
---
# Phase 02 Plan 07: Auth UI Gate Summary
**Phase 2 auth UI gate — SplashScreen / LoginScreen / PostLoginPlaceholderScreen wired to AuthSession via koinViewModel, with externalized Polish strings and a Material 3 seed theme.**
## Performance
- **Duration:** ~10 min (automated tasks)
- **Started:** 2026-04-28T15:31:20Z
- **Automated work completed:** 2026-04-28T15:41:31Z
- **Tasks completed:** 2 of 3 (Task 3 awaits manual iOS Authentik UAT)
- **Files created:** 9
- **Files modified:** 5
## Accomplishments
- Added all seven Phase 2 Compose Resources keys with the Polish scaffold copy from `02-UI-SPEC.md`.
- Added `RecipeTheme` with light/dark Material 3 schemes seeded by `#3B6939` / `#A2D597` and `isSystemInDarkTheme()`.
- Replaced the JetBrains template `App()` body with the auth-gate `when` over `AuthSession.state`, observing via `collectAsStateWithLifecycle` and kicking `AuthSession.initialize()` from `LaunchedEffect`.
- Implemented `SplashScreen`, `LoginScreen`, and `PostLoginPlaceholderScreen` using Material 3 stdlib only — no Scaffold, no Haze, all strings via `stringResource(Res.string.*)`.
- Implemented `LoginViewModel` (mapping AuthSession failures → `auth_error_*` `StringResource` keys, clearing stale errors on retry) and trivial `PostLoginViewModel.onSignOutClick()` delegating to `AuthSession.logout()`.
- Registered both ViewModels in `authModule` via `org.koin.core.module.dsl.viewModel`.
- Added `kotlinx-coroutines-test` to `commonTest` so the wasmJs target can compile coroutine tests (replacing JVM-only `runBlocking` with multiplatform `runTest` in both `LoginViewModelTest` and the existing `AuthSessionTest`).
## Task Commits
1. **Task 1 (RED): Compose Resources, theme seed, failing LoginViewModel tests**`466e4c7` (test)
2. **Task 2 (GREEN): Auth screens, ViewModels, App auth gate**`88f4898` (feat)
3. **Task 2 follow-up: switch commonTest to runTest for wasmJs compatibility**`570652c` (fix)
4. **Task 3 (manual UAT): pending — see Awaiting User UAT below**
## Files Created/Modified
### Created
- `composeApp/src/commonMain/composeResources/values/strings.xml` — Phase 2 auth strings (Polish scaffold).
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` — Material 3 seed theme.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt` — wordmark + progress.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt` — wordmark + button + inline error.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt``LoginScreenState` + `onSignInClick()` mapping.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt` — welcome + logout.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt``onSignOutClick()``AuthSession.logout()`.
- `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt` — five tests covering cancelled/network/unknown/success and clear-error-on-retry.
- `.planning/phases/02-authentication-foundation/deferred-items.md` — log of pre-existing failures.
### Modified
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` — auth-gate `when` body.
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt``viewModel { ... }` registrations.
- `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt``runBlocking``runTest`.
- `composeApp/build.gradle.kts``commonTest` `kotlinx-coroutines-test` dependency.
- `gradle/libs.versions.toml` — added `kotlinx-coroutinesTest` library entry.
## Decisions Made
See frontmatter `key-decisions`.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 — Blocking] commonTest coroutine tests must use `runTest`, not `runBlocking`**
- **Found during:** Task 2 verification, when `./gradlew check` ran the wasmJs test target for the first time.
- **Issue:** `kotlinx.coroutines.runBlocking` is JVM/Native-only and breaks `:composeApp:compileTestKotlinWasmJs`. The pre-existing `AuthSessionTest` (committed in Plan 02-06) used the same pattern and was never wasmJs-tested — `02-06` only ran `:composeApp:jvmTest`. Phase 02-07's verification gate is the first one to catch it.
- **Fix:** Added `org.jetbrains.kotlinx:kotlinx-coroutines-test` to `commonTest`, switched both `AuthSessionTest` and the new `LoginViewModelTest` from `runBlocking` to `runTest`.
- **Files modified:** `composeApp/build.gradle.kts`, `gradle/libs.versions.toml`, `AuthSessionTest.kt`, `LoginViewModelTest.kt`.
- **Verification:** `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileTestKotlinWasmJs` exits 0.
- **Committed in:** `570652c`.
### Out-of-scope discoveries (not fixed; logged)
See `deferred-items.md`:
- `SecureAuthStateStoreContractTest` (Android JVM unit) fails on `master` HEAD before any 02-07 change — Android Keystore unavailable in plain JVM unit tests; needs Robolectric or `androidTest`.
- `composeApp/src/iosMain/.../SecureAuthStateStore.ios.kt:L31` ktlint `property-naming` violation pre-exists on `master`.
Both originate in Plans 02-04 / 02-05 and are out of scope for this UI plan per the executor scope-boundary rule.
## Issues Encountered
- `./gradlew spotlessApply` reformatted many pre-existing files unrelated to 02-07 (because the repo had pre-existing format drift). Those reformats were reverted before commit so the 02-07 commits stay scope-clean. Spotless's failure on the unrelated `SecureAuthStateStore.ios.kt` ktlint rule is logged in `deferred-items.md`.
## Known Stubs
None. The auth gate is fully wired end to end; all rendered text is sourced from Compose Resources, and ViewModels delegate to the real `AuthSession` Koin singleton.
The `PostLoginPlaceholderScreen` itself is a Phase 2 placeholder by design — Phase 3's `HouseholdGate` replaces it. This is documented in `02-UI-SPEC.md` and `02-CONTEXT.md` and is not a stub.
## Threat Flags
None beyond the plan's threat model. The new UI surfaces only render strings and dispatch to `AuthSession`; tokens are never logged or rendered (T-02-07-01). Logout (T-02-07-02) is the only state-changing action wired in `PostLoginViewModel`. Login button explicitly mentions Authentik (T-02-07-03). Refresh failures route silently to `LoginScreen` per `02-UI-SPEC.md`'s refresh-failure UX (T-02-07-04). All copy comes from `composeResources/values/strings.xml` (T-02-07-05).
## User Setup Required
**Manual iOS Authentik UAT (Task 3 — checkpoint:human-verify, blocking):**
Per `02-VALIDATION.md` § Manual-Only Verifications and `docs/authentik-setup.md`:
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 short 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; 401 without token or with wrong-audience token.
Reply with `approved` to mark Phase 2 complete, or describe the failing step (with tokens redacted) so the gate can be re-opened.
## Verification
### Automated — passing
- `./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"` — PASS (5 tests).
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileTestKotlinWasmJs` — PASS.
- Acceptance grep checks: `auth_sign_in_button` Polish copy present, `0xFF3B6939` in `RecipeTheme`, `auth_error_cancelled` referenced in `LoginViewModelTest`, no `Click me!` in `App.kt`, `collectAsState` + `SplashScreen` present in `App.kt`, `auth_welcome_format` in `PostLoginPlaceholderScreen`, no raw Polish strings in any `.kt` source under `dev/ulfrx/recipe/`.
### Automated — pre-existing failures (not introduced by 02-07; tracked in deferred-items.md)
- `:composeApp:testDebugUnitTest` — 2 failures in `SecureAuthStateStoreContractTest`.
- `:composeApp:spotlessKotlinCheck` — 1 ktlint violation in `SecureAuthStateStore.ios.kt`.
### Manual — pending
- iOS Authentik UAT (Task 3 — see User Setup Required above).
## Next Phase Readiness
Phase 3 (households) can now extend `AuthState.Authenticated.householdId` and replace `PostLoginPlaceholderScreen` with `HouseholdGate` without touching `AuthSession` or the auth-gate `when` (it already handles the `is Authenticated` branch).
## Self-Check: PASSED
- All listed created/modified files exist on disk.
- Commits `466e4c7`, `88f4898`, `570652c` exist in `git log`.
- Acceptance grep checks all pass (run inline above).
- `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileTestKotlinWasmJs` exits 0.
- Pre-existing failures unrelated to 02-07 are documented in `deferred-items.md` (verified via `git stash` reproduction on `master` HEAD).
---
*Phase: 02-authentication-foundation*
*Status: Tasks 1+2 complete; Task 3 (manual iOS Authentik UAT) awaiting user verification.*

View File

@@ -0,0 +1,237 @@
# Phase 2: Authentication Foundation - Context
**Gathered:** 2026-04-27
**Status:** Ready for planning
<domain>
## Phase Boundary
End-to-end OIDC + PKCE login to Authentik. App opens Authentik in the system browser via AppAuth, returns with tokens stored securely (Keychain on iOS, EncryptedSharedPreferences on Android), Ktor server validates JWTs via JWKS, JIT-provisions a user row by `sub`, and `GET /api/v1/me` returns the user. "Wyloguj się" wipes local tokens AND calls Authentik's RP-initiated `end_session_endpoint`. Token refresh runs transparently across launches.
**In scope:** OIDC client (AppAuth on iOS+Android, stubs on JVM/Wasm), token storage, token refresh, server JWT validation, JIT user provisioning, `users` table migration, `/api/v1/me` route, login + post-login screens with error handling, `docs/authentik-setup.md`.
**Out of scope (Phase 3):** Households, memberships, invites, household-scoped principal, household onboarding screen. Phase 2's post-login UI is a placeholder; `AuthSession.householdId` is always `null` until Phase 3 lands.
**Out of scope (Phase 4+):** Sync engine, outbox, household-scoped data tables. Phase 2 has no offline write path because there is no household-scoped data yet.
</domain>
<decisions>
## Implementation Decisions
### Client OIDC implementation
- **D-01:** **AppAuth on both mobile platforms.** iOS uses AppAuth-iOS via CocoaPod added to `iosApp/Podfile`; Android uses AppAuth-Android (`net.openid:appauth`). Symmetric `expect class OidcClient` in `composeApp/commonMain/.../auth/`, with `actual` impls in `iosMain` and `androidMain` wrapping each platform's AppAuth. Uses AppAuth's `OIDAuthState` / `AuthState` as the in-memory session shape behind the seam.
- **D-02:** **JVM (Desktop) `actual`: dev-mode env-var stub.** Reads `DEV_AUTH_TOKEN` env var (or hardcoded dev user fallback). Bypasses real OIDC. Desktop is a hot-reload dev tool per Phase 1 D-03, not a release surface — this stub exists to keep `./gradlew :composeApp:run` working without standing up the full Authentik flow on dev machines.
- **D-03:** **Wasm `actual`: `NotImplementedError("Wasm OIDC: v2")` stub.** Preserves `wasmJs` as a build target without committing to a browser-redirect implementation. If/when Wasm becomes a release surface, this gets replaced with a `window.location.href`-based browser-redirect flow (different code path from native AppAuth).
- **D-04:** **Coroutine bridge.** `OidcClient.login()` and `.refresh()` are `suspend` functions. iOS/Android `actual` impls use `suspendCancellableCoroutine` to bridge AppAuth's callback API. Cancellation cancels the underlying AppAuth request.
### Authentik provider configuration
- **D-05:** **Provider type: Public + PKCE S256.** Mobile apps are public clients per OAuth 2 RFC 8252 — no shippable secret. PITFALLS.md #8 enforces this.
- **D-06:** **Scopes requested: `openid profile email offline_access`.** `offline_access` is required for AUTH-04 (token refresh across launches); without it, Authentik may not issue a refresh token. `profile` + `email` populate `display_name` and `email` for JIT-provisioning.
- **D-07:** **`aud` claim shape pinned to single string equal to client_id.** Authentik can emit array OR string per provider config (PITFALLS.md #7). Pin to string in the provider config; Ktor `JWTAuth.withAudience(clientId)` validates against it. Document the pin in `docs/authentik-setup.md` and add an integration test that asserts wrong-`aud` → 401.
- **D-08:** **Signing alg: RS256.** Default for Authentik. Verify `kid` resolves via JWKS cache. Document in setup guide.
- **D-09:** **Redirect URI: custom URL scheme `recipe://callback`.** iOS: `CFBundleURLTypes` in `iosApp/iosApp/Info.plist`. Android: `<intent-filter>` with `android:scheme="recipe" android:host="callback"` in `composeApp/src/androidMain/AndroidManifest.xml`. AppAuth + PKCE state/nonce makes the theoretical interception attack non-exploitable. Universal Links / App Links explicitly deferred (see Deferred Ideas).
- **D-10:** **`docs/authentik-setup.md` is a Phase 2 deliverable.** Documents the exact provider config: Public + PKCE S256, redirect URIs registered (`recipe://callback`), scopes, audience pinned to single string, RS256 signing, JWKS endpoint URL. Goal: anyone (or future-you on a new homelab) can recreate the Authentik provider from scratch in ~5 minutes by following the doc.
### Configuration plumbing
- **D-11:** **Client OIDC config hardcoded in `shared/commonMain/Constants.kt`.** Constants: `OIDC_ISSUER` (e.g., `https://auth.<homelab>.tld/application/o/recipe/`), `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URI` (`recipe://callback`). PITFALLS.md tech-debt table marks this "Acceptable: v1 single-environment only." Promote to BuildConfig-style Gradle injection only if a staging Authentik appears.
- **D-12:** **Server OIDC config via env vars in `application.conf`.** Variables: `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL` (optional — derive from issuer if absent). Matches Phase 1 D-16's `DATABASE_URL` pattern. Localhost defaults match Authentik in user's homelab.
### Token storage
- **D-13:** **Persistence: full AppAuth `AuthState` JSON blob via `multiplatform-settings`.** AppAuth's `AuthState.serialize()` returns a ~2KB JSON containing tokens + provider config + last error + registration response. Restoring across launches is one-line: `AuthState.jsonDeserialize(serialized)`. Settings backend: Keychain on iOS, EncryptedSharedPreferences on Android — both handled by `multiplatform-settings`'s platform-secure adapters.
- **D-14:** **iOS Keychain accessibility: `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.** Standard for OAuth refresh tokens. Excluded from iCloud Keychain backup. Background refresh would work pre-unlock if v2 ever adds it; v1 has no background work but this doesn't hurt.
- **D-15:** **One AuthState blob per app install.** No per-user keying — the user is whoever last logged in. Logout deletes the blob entirely.
### Token refresh
- **D-16:** **Proactive refresh via AppAuth `performActionWithFreshTokens`.** Wrap every authenticated Ktor call in this. AppAuth refreshes if access token expiry is within its threshold (~60s). Returns a fresh access token to the caller; updates the persisted `AuthState`.
- **D-17:** **Reactive 401 fallback via Ktor `Auth { bearer { refreshTokens { ... } } }`.** Catches the rare case where proactive refresh missed (clock drift, mid-call expiry). Coalesces concurrent refreshes (single-flight is library-provided on both Ktor's plugin and AppAuth's `performActionWithFreshTokens`).
- **D-18:** **Refresh-failure UX: silent.** When refresh returns `invalid_grant` (revoked / expired / Authentik forgot us), `AuthSession.state` transitions `Authenticated → Unauthenticated`. App routes back to the login screen. No modal, no toast. Logged at `Kermit.w` for diagnostics.
### Logout
- **D-19:** **RP-initiated end-session.** "Wyloguj się" does two things atomically: (a) call Authentik's `end_session_endpoint` (per OIDC spec) with `id_token_hint`; (b) delete the persisted `AuthState` blob from secure storage. Order: end-session first, then local wipe — if end-session fails (network), still wipe locally so the user isn't stuck. Correct semantics for shared household devices: next "Zaloguj się" forces fresh credentials, doesn't silently SSO.
- **D-20:** **AppAuth's `EndSessionRequest` API drives this on both platforms.** Android: `AuthorizationService.performEndSessionRequest(...)`. iOS: `OIDExternalUserAgent` with the end-session endpoint.
### Server-side validation (carries forward from PITFALLS.md #7)
- **D-21:** **`install(Authentication) { jwt("authentik") { ... } }`** with explicit `verifier(jwkProvider, issuer)`, `.withIssuer(issuer)`, `.withAudience(clientId)`, `acceptLeeway(30)` (seconds), and validate-by-claims block that asserts `sub` is non-null. Provider name `"authentik"` is the route auth scope.
- **D-22:** **JWKS provider configuration.** `JwkProviderBuilder(issuerUrl).cached(10, 15, MINUTES).rateLimited(10, 1, MINUTES).build()`. Cache size 10 (one issuer × ~3 active keys with rotation headroom). Rate limit defends against pathological JWKS-thrashing during key rotation.
- **D-23:** **Audit-grade logging discipline.** Never log the `Authorization` header. Custom Ktor `CallLogging` filter redacts it. `Kermit` on the client never logs token bodies. Token-related debug uses `Authorization: Bearer <token>``Authorization: Bearer <redacted>`.
### Server data model + JIT provisioning
- **D-24:** **Phase 2 ships `V1__users.sql`** (Flyway migration). Schema:
```sql
CREATE TABLE 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()
);
CREATE INDEX users_sub_idx ON users(sub);
```
Phase 3 layers `V2__households_memberships_invites.sql` on top. **ROADMAP.md Phase 3 description gets a one-line edit:** drop `users` from "users, households, memberships, invites" → "households, memberships, invites".
- **D-25:** **JIT-provisioning logic.** On every authenticated request, the auth phase's `PrincipalResolver` does:
```sql
INSERT INTO users (sub, email, display_name)
VALUES (:sub, :email, :name)
ON CONFLICT (sub) DO UPDATE
SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
updated_at = now()
RETURNING *;
```
Updates email/display_name on every login so claim drift (user changed email in Authentik) is captured. Returns the row so the route handler can use it. Phase 3's `PrincipalResolver` extends this with a household lookup.
- **D-26:** **Exposed DSL only, `newSuspendedTransaction`.** Per CLAUDE.md #5 and PITFALLS.md #5/#6. Phase 2 establishes the pattern: `newSuspendedTransaction(Dispatchers.IO) { ... }` for every coroutine-touching DB call. No DAO.
- **D-27:** **`/api/v1/me` route.** Behind `authenticate("authentik")`. Returns the JIT-resolved user row as a `MeResponse` DTO (lives in `shared/commonMain/.../shared/dto/`). Shape: `{ id: UUID, sub: String, email: String, displayName: String }`.
### Client AuthSession state model
- **D-28:** **Sealed `AuthState` shape, forward-compatible with Phase 3:**
```kotlin
sealed class AuthState {
data object Loading : AuthState()
data object Unauthenticated : AuthState()
data class Authenticated(
val user: User,
val householdId: HouseholdId? = null, // Phase 2: always null. Phase 3 fills.
) : AuthState()
}
```
Phase 2 always emits `Authenticated(user, householdId = null)`. Phase 3 widens the meaning of `householdId` (resolved from `/api/v1/me` extended response). No sealed-class refactor needed at Phase 2/3 boundary.
- **D-29:** **`AuthSession` is a Koin singleton in `authModule`.** Exposes `state: StateFlow<AuthState>`, `login()`, `logout()`, `getAccessToken(): String?`. Owns the AppAuth `AuthState` blob and its persistence via `multiplatform-settings`. Hot at `App()` start: deserializes persisted blob, transitions to `Loading → (Authenticated | Unauthenticated)` based on whether the refresh token is still valid.
- **D-30:** **Auth gate composable.** `App()` reads `AuthSession.state.collectAsState()` and routes:
- `Loading` → splash placeholder
- `Unauthenticated` → `LoginScreen`
- `Authenticated` → `PostLoginPlaceholderScreen` (Phase 2) → `HouseholdGate` (Phase 3 replaces this)
### Login + post-login UI
- **D-31:** **Login screen: minimal.** App name + "Zaloguj się przez Authentik" button. Centered, plenty of breathing room (matches PROJECT.md "calmer typography" direction). No tagline, no marketing copy. Polish strings via Compose Resources scaffold (real i18n pass is Phase 11).
- **D-32:** **Login error states (inline below the button):**
- User cancels system browser → "Logowanie anulowane. Spróbuj ponownie." (Polish scaffold copy; refined in Phase 11)
- Network unreachable / Authentik down → "Nie można połączyć z Authentik. Sprawdź połączenie."
- Token exchange / validation failure → "Coś poszło nie tak. Spróbuj ponownie."
- Inline (snackbar-style) error message; button stays enabled for retry.
- **D-33:** **Post-login placeholder: `Witaj, {displayName}!` + "Wyloguj się" button.** Visually confirms login worked end-to-end and lets you exercise logout. Phase 3 replaces this entire screen with the household onboarding flow.
### Strings (Polish, scaffold)
- **D-34:** **All user-facing strings in Compose Resources from day 1** (CLAUDE.md #9). Keys: `auth_sign_in_button`, `auth_sign_out_button`, `auth_welcome_format`, `auth_error_cancelled`, `auth_error_network`, `auth_error_unknown`. Polish copy is scaffold-quality; Phase 11 does the polished pass with proper plural forms and tone.
### Claude's Discretion
- Exact `Koin` `authModule` Definition Style (`single<AuthSession> { ... }` vs `single { AuthSession(get(), get()) }`).
- Ktor Client `Auth { bearer { ... } }` configuration boilerplate — refresh-tokens block, token loader, `sendWithoutRequest` policy.
- Whether `MeResponse` DTO and `User` domain model are the same type in `shared/` or separate (DTO + domain mapper).
- Concrete `kotlinx.uuid` vs. `kotlin.uuid.Uuid` (Kotlin 2.0+) for the `User.id` type — pick whichever pairs cleanly with Exposed UUID columns and `kotlinx.serialization`.
- Whether the AppAuth-iOS CocoaPod is added via `cocoapods { pod("AppAuth") { ... } }` Gradle DSL or via a hand-written Podfile in `iosApp/`. Either is fine; Gradle DSL is the JetBrains-recommended pattern for KMP-managed pods.
- Splash placeholder visual (during `Loading` state) — solid color, app name, or progress indicator. Phase 11 polishes.
- Whether `OIDC_ISSUER` ends with a trailing slash (Authentik is sensitive here per PITFALLS.md #8). Pin and document either way.
- Logger tag/level for AppAuth events (debug/info on iOS — bridged via Kermit's iOS sink).
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Product + scope anchors
- `.planning/PROJECT.md` — Locked tech stack (§ Key Decisions), particularly the Authentication & identity, Mobile OIDC, and Token validation rows
- `.planning/REQUIREMENTS.md` — AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06 are the in-scope requirements for this phase
- `.planning/ROADMAP.md` § "Phase 2: Authentication Foundation" — phase goal + 5 success criteria. **NOTE:** Phase 3's description in ROADMAP gets a one-line edit per D-24 — `users` is removed from Phase 3's table list and lands in Phase 2 instead.
### Architecture + pitfalls (load-bearing)
- `.planning/research/ARCHITECTURE.md` — § Component Responsibilities (AuthSession, Ktor route, PrincipalResolver), § Pattern 3 (household-scope enforcement — Phase 2 only does the auth principal layer; household scope is Phase 3), § Build Order Implication ("auth + a working Ktor skeleton that echoes an authenticated principal" is the load-bearing first feature)
- `.planning/research/PITFALLS.md` — Phase 2 must prevent: **Pitfall #7** (Ktor JWT — audience, issuer, leeway, JWKS cache; D-21/D-22 directly mitigate); **Pitfall #8** (OIDC redirect URI + missing PKCE; D-05/D-09 mitigate). Tech-debt table row "Hardcoded OIDC issuer/client_id in shared/commonMain" is the explicit acceptance for D-11.
- `.planning/research/SUMMARY.md` § "Phase 2: Authentication foundation" — research-driven rationale for AppAuth + ASWebAuth + ktor-server-auth-jwt path; § "Gaps to Address" lists "Authentik-specific OIDC flow details" and "Mobile OIDC library choice for iOS" — both resolved by this CONTEXT.md.
### Project conventions
- `CLAUDE.md` — Non-negotiable conventions. Items #5 (Exposed DSL only), #6 (`newSuspendedTransaction`), #8 (`shared/commonMain` stays light — only `MeResponse` DTO crosses), #9 (strings externalized day 1) all touch Phase 2.
- `.planning/phases/01-project-infrastructure-module-wiring/01-CONTEXT.md` — D-14 (Koin `appModule` placeholder; Phase 2 adds `authModule`), D-15 (Kermit logger available for auth-flow debug), D-16 (server `application.conf` env-var pattern; Phase 2 extends with `OIDC_*` vars), D-19 (`shared/commonMain` purity rule).
### External docs to consult during research/planning
- AppAuth-Android: https://github.com/openid/AppAuth-Android — `OIDAuthState` lifecycle, `AuthorizationService.performTokenRequest`, `performEndSessionRequest`
- AppAuth-iOS: https://github.com/openid/AppAuth-iOS — `OIDAuthState`, `OIDExternalUserAgent`, CocoaPod integration with KMP
- Ktor `Auth { bearer { refreshTokens { ... } } }`: https://ktor.io/docs/client-bearer-auth.html
- Ktor `ktor-server-auth-jwt` + JwkProviderBuilder: https://ktor.io/docs/server-jwt.html
- Authentik OIDC provider docs: https://docs.goauthentik.io/docs/providers/oauth2/ (provider config, scopes, RP-initiated logout, `aud` shape)
No external ADRs or specs yet — project is greenfield; decisions flow from PROJECT.md + research/ files + this CONTEXT.md.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable assets (what Phase 1 left in place)
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`** — comment literally reads `// Phase 2 adds authModule`. Ship `authModule = module { single { AuthSession(...) }; single { OidcClient }; ... }` and wire into the `appModule` `modules(...)` list.
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt`** — `initKoin()` already callable. iOS-side bridge `KoinIosKt.doInitKoin()` already wired in `iOSApp.swift`. Phase 2 adds dependencies, not bootstrap code.
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`** — current `App()` is a template button-and-greeting. Phase 2 replaces the body with the auth-gated routing (`Loading → LoginScreen → PostLoginPlaceholder`). Existing `MaterialTheme { ... }` wrapper stays.
- **`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/`** — Kermit bootstrap exists (Phase 1 D-15). Auth flow uses `Logger.withTag("auth")` for OIDC events.
- **`server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`** — `install(ContentNegotiation) { json() }` and `Database.migrate(this)` already wired. Phase 2 adds `install(Authentication) { jwt("authentik") { ... } }` between ContentNegotiation and `configureRouting()`. New routes go in a `configureAuth()` function alongside `configureRouting()`.
- **`server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`** — Flyway already wired and runs on startup (Phase 1 D-16). Phase 2 drops `V1__users.sql` into `server/src/main/resources/db/migration/`. Database connection is fail-loud per Phase 1 — Phase 2 inherits this.
- **`shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/`** — empty package scaffold ready (Phase 1 D-19). Phase 2 lands `User` (or `MeResponse`) DTO + `Constants.kt` (with `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URI`).
- **`gradle/libs.versions.toml`** — Koin/Kermit/Flyway/Postgres/Ktor catalog entries exist. **Phase 2 ADDS:** `multiplatform-settings` + `multiplatform-settings-no-arg` (or coroutines extension), `ktor-server-auth`, `ktor-server-auth-jwt`, `appauth-android` (`net.openid:appauth`), AppAuth-iOS via CocoaPod. Plus a `kotlinx-uuid` (or stdlib `kotlin.uuid` if Kotlin 2.3 lands stable) library if not already covered for the `User.id` UUID type.
### Established patterns Phase 2 must respect
- **JetBrains template style** — plugin application via aliases inside `recipe.*` convention plugins (Phase 1 D-06D-09). Phase 2's `composeApp/build.gradle.kts` does NOT add direct alias references — adds to the convention plugins or to the module's existing dependency block.
- **JVM toolchain split** — JVM 21 for server/desktop/`shared/jvm`; JVM 11 for Android (Phase 1 D-08). Auth code in `composeApp/commonMain` compiles to both; ensure no JVM-21-only API leaks into commonMain.
- **`./gradlew check` is the local gate** (Phase 1 D-13). Phase 2's auth integration tests run under `:server:test`. Client unit tests under `:composeApp:commonTest`.
- **Server config: `application.conf` reading env vars with localhost defaults** (Phase 1 D-16). `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL` follow the same pattern.
### Integration points
- **iOS Info.plist** — `iosApp/iosApp/Info.plist` needs `CFBundleURLTypes` block registering `recipe://` scheme. AppAuth-iOS ATS exception NOT needed for the homelab (use a real cert per PITFALLS.md "Looks Done But Isn't" checklist).
- **Android manifest** — `composeApp/src/androidMain/AndroidManifest.xml` needs `<intent-filter>` on AppAuth's `RedirectUriReceiverActivity` (or your own activity declared per AppAuth-Android docs) for `android:scheme="recipe" android:host="callback"`.
- **iOSApp.swift** — current `KoinIosKt.doInitKoin()` runs in `init`. AppAuth-iOS's `currentAuthorizationFlow` global lives in the SwiftUI app and must receive callbacks from `application(_:open:options:)` or the SwiftUI `.onOpenURL { }` modifier. Add this wiring alongside the existing Koin init.
- **Phase 3 hand-off seam** — `AuthState.Authenticated` carries a nullable `householdId`. Phase 3's onboarding flow updates this via a yet-to-exist `AuthSession.onHouseholdEstablished(HouseholdId)` method. Phase 2 doesn't expose this method but the state model is ready.
### What must NOT change in Phase 2
- Package namespace `dev.ulfrx.recipe` (CLAUDE.md, Phase 1 D-20).
- Phase 1's iOS binary flags in `gradle.properties` (D-18).
- Phase 1's convention plugins (`recipe.*`) — they're applied as-is; Phase 2 adds module-level dependencies, not new conventions.
- `shared/commonMain` purity (D-19) — only DTOs cross. No Ktor Client, no AppAuth, no `multiplatform-settings` imports.
</code_context>
<specifics>
## Specific Ideas
- **"Wyloguj się" must mean it.** Local-only logout is a junk feature on a shared household device. RP-initiated end-session is the only logout that fulfills the user's expectation when they hand the phone to their partner.
- **AppAuth on both platforms is the symmetry win.** User is new to KMP/CMP idioms; symmetric `AuthState` shape across iOS and Android means one mental model. Hand-rolled was an option but the asymmetry tax (AppAuth on Android, custom on iOS) costs more than the dependency saves.
- **`AuthState.Authenticated(user, householdId: HouseholdId? = null)` is the explicit forward-compat decision.** Phase 3 is literally next; baking the field in now saves a sealed-class refactor across every call-site. This is the one allowed instance of "modeling something Phase 2 doesn't use" — justified by Phase 2/3 adjacency.
- **Phase 2 owns `users.sql`.** The auth phase owning the auth-principal table is the clean boundary; Phase 3 layers households+memberships+invites on top. ROADMAP edit is a single line (Phase 3 description: drop `users` from the list).
- **Token storage: full AppAuth `AuthState` blob, not hand-rolled.** AppAuth's serialized blob makes refresh "just work" across launches. The privacy concern (extra metadata stored in Keychain) is academic for a personal app. Hand-rolling token-only storage is the kind of "library handles this for you, don't reinvent it" trap to avoid as a KMP newcomer.
- **`docs/authentik-setup.md` is non-optional.** The provider config is the single most fragile piece of Phase 2 — if `aud` is wrong, JWKS URL is wrong, scopes are missing, or PKCE is forgotten, you get silent 401s with no useful error. Documenting it makes Phase 2 reproducible.
</specifics>
<deferred>
## Deferred Ideas
- **Universal Links / App Links** — rejected for v1; revisit only if (a) app gains broader distribution beyond the household, or (b) Apple/Google deprecate custom schemes for OIDC redirects (no signal of this in 2026).
- **BuildConfig-style Gradle injection of OIDC config** — Constants.kt is fine for v1 single-environment per PITFALLS.md tech-debt acceptance. Promote when a staging Authentik becomes a real need (estimated never for this app's lifetime).
- **Real Desktop OIDC** — JVM target gets a `DEV_AUTH_TOKEN` stub. If Desktop ever becomes a release surface (currently scoped to dev tool only per Phase 1 D-03), implement loopback-redirect OIDC: open system browser to Authentik, AppAuth-Java equivalent or hand-roll a tiny localhost:N HTTP listener to capture the code.
- **Wasm OIDC implementation** — `wasmJs` target gets `NotImplementedError` stub. If/when Wasm becomes a release surface, implement browser-redirect OIDC: `window.location.href = authUrl`, handle `code` param on return, store tokens in `sessionStorage`. Different code path from native AppAuth — won't reuse current `OidcClient` actuals.
- **"Wyloguj się i zapomnij sesję" two-tier logout** — current single "Wyloguj się" is RP-initiated. If a workflow emerges where users want fast re-login after intentional logout (testing, account switching), add a second menu item for local-only logout.
- **Background token refresh** — v1 has no background work. Refresh runs proactively on the next authenticated call. If/when background sync is added (PROJECT.md v2 SYNC2-01 SSE-based sync), Keychain accessibility may need re-evaluation.
- **Apple Sign-in as a first-class button** — explicitly out of scope per PROJECT.md / REQUIREMENTS.md. Authentik can federate Apple Sign-in upstream if ever wanted.
- **Per-user persisted `AuthState`** — D-15 keys the AuthState blob globally (not per-user). Multi-account on a single device is out of scope; one user per install is the v1 model.
- **Modal/toast for refresh-failure UX** — Phase 2 ships silent transition. If user complaints emerge ("why was I logged out without warning?"), add a toast on the login screen.
- **Authentik provisioning automation** — `docs/authentik-setup.md` is manual. A Terraform/Ansible playbook for the homelab Authentik is post-v1.
- **JWT validation tests at the Authentik-emit level** — Phase 2 ships unit tests with hand-crafted JWTs (using a test JWKS). Integration tests against a real Authentik instance are deferred to Phase 11 (deployment) where the homelab Authentik is the test target.
</deferred>
---
*Phase: 02-authentication-foundation*
*Context gathered: 2026-04-27*

View File

@@ -0,0 +1,185 @@
# Phase 2: Authentication Foundation - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in `02-CONTEXT.md` — this log preserves the alternatives considered.
**Date:** 2026-04-27
**Phase:** 02-authentication-foundation
**Areas discussed:** iOS OIDC wrapper approach, Redirect URI + Authentik provider config, Token lifecycle (storage/refresh/logout), Phase 2/3 boundary + login UX shape
---
## iOS OIDC wrapper approach
**Original question:** "How should iOS speak OIDC + PKCE to Authentik?"
| Option | Description | Selected |
|--------|-------------|----------|
| AppAuth on both platforms | AppAuth-Android + AppAuth-iOS via CocoaPod. Symmetric expect/actual seam. Battle-tested, library-managed refresh, PKCE built-in. | ✓ |
| Hand-rolled ASWebAuthenticationSession wrapper | Thin Swift wrapper + Kotlin-side PKCE/token-exchange. Smallest deps; ~250 LOC owned. | |
| Defer to researcher — evaluate community KMP libs | Have researcher survey 2026 KMP OIDC library landscape first. | |
| Hand-rolled iOS, AppAuth Android | Asymmetric. Avoids CocoaPods on iOS. | |
**User's clarification:** Asked whether AppAuth would block Desktop and Wasm support; questioned whether to abandon Wasm.
**Reframed answer:** Native OIDC is platform-specific regardless of choice — AppAuth doesn't make Desktop/Wasm worse. Decision: AppAuth on mobile + dev-mode env-var stub (`DEV_AUTH_TOKEN`) for Desktop + `NotImplementedError` stub for Wasm. Don't abandon Wasm yet — cost of preserving (~5-30 LOC stubs per platform-touching phase) is much smaller than cost of resurrecting it later. Revisit only if stubbing tax compounds painfully.
**User's choice:** AppAuth on both platforms.
**Notes:** User's pushback on cross-target implications was correct and surfaced a load-bearing decision (Desktop/Wasm stubs). Recorded as D-01 through D-04 in CONTEXT.md.
---
## Redirect URI + Authentik provider config
### Sub-question 1: Redirect URI scheme
| Option | Description | Selected |
|--------|-------------|----------|
| Custom URL scheme `recipe://callback` | iOS Info.plist + Android intent-filter. ~10 lines. AppAuth + PKCE state/nonce makes interception non-exploitable. | ✓ |
| Universal Links / App Links via HTTPS | Requires hosting apple-app-site-association + assetlinks.json on homelab. Cert SHA pinning. Cryptographically tied to domain. | |
| Custom now, Universal Links later if needed | Same as option 1 for v1; documented in deferred. | |
**User's choice:** Custom URL scheme `recipe://callback`.
### Sub-question 2: Client OIDC config location
| Option | Description | Selected |
|--------|-------------|----------|
| Hardcoded in shared/commonMain/Constants.kt | Single source of truth. Per PITFALLS.md "acceptable v1 single-environment only" tech-debt note. | ✓ |
| Gradle property → BuildConfig-style generated Kotlin | Build-time injection; supports dev/staging/prod variants. | |
| Hybrid: hardcoded defaults, Gradle override available | Constants with Gradle-property overrides. | |
**User's choice:** Hardcoded in `shared/commonMain/Constants.kt`.
### Sub-question 3: OIDC scopes
| Option | Description | Selected |
|--------|-------------|----------|
| openid profile email offline_access | Standard mobile-app OIDC scope set. JIT-provisioning gets sub + email + display_name + refresh token. | ✓ |
| openid email offline_access (no profile) | Drops display_name; UI shows email everywhere. | |
| openid offline_access (minimal) | Sub + refresh token only; no email or name. | |
**User's choice:** `openid profile email offline_access`.
**Notes:** No-fork recommendations also recorded: pin Authentik `aud` to single client_id string; ship `docs/authentik-setup.md` as a Phase 2 deliverable. RS256 signing alg confirmed (Authentik default, matches PITFALLS.md #7 expectation).
---
## Token lifecycle (storage, refresh, logout)
### Sub-question 1: Storage backend
| Option | Description | Selected |
|--------|-------------|----------|
| Full AppAuth AuthState blob, AfterFirstUnlockThisDeviceOnly | ~2KB JSON via multiplatform-settings. AppAuth's `.serialize()`/`.jsonDeserialize()`. iCloud-Keychain-excluded. | ✓ |
| Just access + refresh + expiry, AfterFirstUnlockThisDeviceOnly | ~200 bytes explicit fields. We own the deserialization. | |
| Full AppAuth AuthState blob, WhenUnlockedThisDeviceOnly | Stricter accessibility; blocks pre-unlock work (none in v1, but blocks future background sync). | |
**User's choice:** Full AppAuth AuthState blob, AfterFirstUnlockThisDeviceOnly.
### Sub-question 2: Refresh policy
| Option | Description | Selected |
|--------|-------------|----------|
| Proactive (AppAuth performActionWithFreshTokens) + reactive 401 fallback | Both layers. Library-provided single-flight on each. UX: refresh is invisible. | ✓ |
| Reactive only (Ktor bearer plugin) | Simpler. One wasted round-trip per expiry boundary. | |
| Proactive only (AppAuth, no Ktor bearer) | Skips Ktor's plugin. No clock-drift recovery. | |
**User's choice:** Proactive + reactive 401 fallback.
### Sub-question 3: Refresh-failure UX
| Option | Description | Selected |
|--------|-------------|----------|
| Silent — transition to Unauthenticated, return to login | No modal, no toast. Cleanest UX. | ✓ |
| Surfaced — modal "Twoja sesja wygasła, zaloguj się ponownie" | Explicit dialog before returning to login. | |
| Surfaced as toast on login screen | Silent transition + non-blocking snackbar. | |
**User's choice:** Silent.
### Sub-question 4: Logout semantics
| Option | Description | Selected |
|--------|-------------|----------|
| RP-initiated end-session | Wipe local tokens AND call Authentik's end_session_endpoint with id_token_hint. Forces fresh credentials on next login. | ✓ |
| Local-only token wipe | Authentik session persists; next login silently SSO's. | |
| Both — local default + "forget session" as long-press / settings option | Two-tier UX. Overkill for v1. | |
**User's choice:** RP-initiated end-session.
**Notes:** User accepted all four recommendations without challenge. Decisions recorded as D-13 through D-20 in CONTEXT.md.
---
## Phase 2/3 boundary + login UX shape
### Sub-question 1: Server schema split
| Option | Description | Selected |
|--------|-------------|----------|
| Phase 2 owns V1__users.sql; Phase 3 layers V2__households_memberships_invites.sql | Auth phase owns auth-principal table. JIT-provisioning writes a real row in Phase 2. ROADMAP Phase 3 description gets a one-line edit (drop `users`). | ✓ |
| Phase 3 ships V1__init.sql with everything; Phase 2 returns JWT-derived user | Single migration in Phase 3. Phase 2 doesn't persist; SC#5 gets rewritten. | |
| Phase 2 ships V1__users.sql with JIT-insert wired but table only used in Phase 3 | Schema lands but doesn't see traffic until Phase 3. Same complexity, no win. | |
**User's choice:** Phase 2 owns `V1__users.sql`; Phase 3 layers `V2`.
### Sub-question 2: AuthSession state shape
| Option | Description | Selected |
|--------|-------------|----------|
| Forward-compat: Authenticated(user, householdId: HouseholdId?) — null in Phase 2 | Carries Phase 3's needs from day 1. No sealed-class refactor at Phase 2/3 boundary. Mild forward-compat justified by adjacency. | ✓ |
| Phase 2 minimal: Authenticated(user) only | Strict YAGNI. Phase 3 widens the sealed shape; refactor cost is small. | |
**User's choice:** Forward-compat with nullable `householdId`.
### Sub-question 3: Login screen shape
| Option | Description | Selected |
|--------|-------------|----------|
| Minimal: app name + "Zaloguj się przez Authentik" button | Centered, no marketing copy. Inline error states for cancelled/network/exchange failures. | ✓ |
| Branded: app name + tagline + button + disclosure | Adds tagline + "Otworzy się w przeglądarce" disclosure. | |
| Stub: just a button labeled "login" | Bare-minimum; Phase 11 polishes. | |
**User's choice:** Minimal app name + button.
### Sub-question 4: Post-login UI in Phase 2
| Option | Description | Selected |
|--------|-------------|----------|
| Placeholder "Witaj, {displayName}!" + Wyloguj button | Confirms login worked end-to-end. Lets you exercise logout. Phase 3 replaces wholesale. | ✓ |
| Empty state "Brak gospodarstwa" + Wyloguj button | Forward-compat: this IS Phase 3's "no household yet" state. | |
| Just route back to login screen with token persisted | No post-login UI; verify via /api/v1/me curl. | |
**User's choice:** Placeholder welcome screen.
**Notes:** All four sub-questions accepted recommendations. Decisions recorded as D-24 through D-33 in CONTEXT.md.
---
## Claude's Discretion
The following implementation details were left to Claude's judgment during planning/execution:
- Exact Koin `authModule` definition style (`single<T> { ... }` vs `single { T(get(), ...) }`)
- Ktor Client `Auth { bearer { ... } }` plugin configuration boilerplate
- Whether `MeResponse` DTO and `User` domain model are unified or separate
- UUID library choice for `User.id` (`kotlinx.uuid` vs `kotlin.uuid.Uuid` if Kotlin 2.3 is stable)
- AppAuth-iOS CocoaPod integration via Gradle DSL (`cocoapods { pod("AppAuth") }`) vs hand-written Podfile
- Splash placeholder visual during `Loading` state
- `OIDC_ISSUER` trailing-slash convention (pin and document)
- Logger tag/level for AppAuth events
## Deferred Ideas
The following ideas surfaced during discussion and were noted for future phases or v2:
- Universal Links / App Links (deferred unless distribution broadens or custom schemes get deprecated)
- BuildConfig-style Gradle config injection (defer until staging Authentik is a real need)
- Real Desktop OIDC (deferred unless Desktop becomes a release surface)
- Wasm OIDC implementation (deferred to v2; native AppAuth path won't reuse)
- Two-tier logout ("forget session" long-press)
- Background token refresh
- Apple Sign-in first-class button (PROJECT.md says Authentik federates upstream)
- Per-user persisted AuthState (multi-account is post-v1)
- Modal/toast for refresh-failure UX (revisit if users complain about silent logout)
- Authentik provisioning automation (Terraform/Ansible — post-v1)
- Integration tests against real Authentik (deferred to Phase 11 deployment)

View File

@@ -0,0 +1,815 @@
# Phase 2: Authentication Foundation - Pattern Map
**Mapped:** 2026-04-27
**Files analyzed:** 56 new/modified files or file groups
**Analogs found:** 48 / 56
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `gradle/libs.versions.toml` | config | build-config | `gradle/libs.versions.toml` | exact |
| `shared/build.gradle.kts` | config | build-config | `server/build.gradle.kts` + `shared/build.gradle.kts` | role-match |
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` | config | transform | `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt` | role-match |
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt` | model | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `Health` DTO | partial |
| `shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` | model | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `Health` DTO | partial |
| `shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt` | test | transform | `shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt` | role-match |
| `server/build.gradle.kts` | config | build-config | `server/build.gradle.kts` | exact |
| `server/src/main/resources/application.conf` | config | request-response | `server/src/main/resources/application.conf` | exact |
| `server/src/main/resources/db/migration/V1__users.sql` | migration | CRUD | `server/src/main/resources/db/migration/.gitkeep` + `Database.kt` Flyway path | partial |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt` | config | request-response | `server/src/main/resources/application.conf` + `Database.kt` config reads | role-match |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` | middleware | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` plugin install pattern | role-match |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt` | service | CRUD | `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` fail-loud DB boundary | partial |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` | model | CRUD | `V1__users.sql` planned migration + Exposed DSL decision | no existing analog |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt` | route | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` `configureRouting()` | exact |
| `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | controller | request-response | `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` | exact |
| `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | service | file-I/O | `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` | exact |
| `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtAuthTest.kt` | test | request-response | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | exact |
| `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt` | test utility | transform | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` | partial |
| `composeApp/build.gradle.kts` | config | build-config | `composeApp/build.gradle.kts` | exact |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` | service | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` expect-free common seam style | partial |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt` | model | event-driven | `shared` DTO style + `kotlin.test` examples | partial |
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` | service | event-driven | `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` | role-match |
| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` | service | event-driven | `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/di/KoinIos.kt` | role-match |
| `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt` | service | transform | `composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt` | role-match |
| `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` | service | transform | `composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt` | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt` | model | event-driven | `shared` model/test shape | partial |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt` | service | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` Koin singleton hook | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/TokenStore.kt` | service | file-I/O | `tools/verify-shared-pure.sh` persistence-boundary guard | partial |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` | service | request-response | no Ktor client analog; use research Ktor bearer pattern | no existing analog |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt` | service | request-response | `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` client GET pattern | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt` | provider | dependency-injection | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | exact |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | provider | dependency-injection | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | exact |
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SettingsFactory.android.kt` | service | file-I/O | `MainApplication.kt` Android context injection | role-match |
| `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SettingsFactory.ios.kt` | service | file-I/O | `KoinIos.kt` iOS actual bridge | partial |
| `composeApp/src/*Main/kotlin/dev/ulfrx/recipe/auth/HttpClientEngine.*.kt` | utility | request-response | platform `main.kt` target-specific files | role-match |
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt` | controller | event-driven | `MainActivity.kt` | exact |
| `composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt` | provider | dependency-injection | `MainApplication.kt` | exact |
| `composeApp/src/androidMain/AndroidManifest.xml` | config | event-driven | `AndroidManifest.xml` | exact |
| `iosApp/iosApp/Info.plist` | config | event-driven | `Info.plist` | exact |
| `iosApp/iosApp/iOSApp.swift` | controller | event-driven | `iOSApp.swift` | exact |
| `iosApp/Podfile` | config | build-config | no existing Podfile; use `composeApp/build.gradle.kts` iOS framework block | no existing analog |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` | component | transform | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` `MaterialTheme` wrapper | exact |
| `composeApp/src/commonMain/composeResources/values/strings.xml` | config | transform | `composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml` | partial |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt` | controller | event-driven | no ViewModel analog; use Koin/viewmodel deps and UI-SPEC method-per-action contract | no existing analog |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt` | component | event-driven | `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | role-match |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt` | controller | event-driven | no ViewModel analog; use same shape as `LoginViewModel` | no existing analog |
| `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` | test | event-driven | `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ComposeAppCommonTest.kt` | role-match |
| `docs/authentik-setup.md` | docs | manual-UAT | `README.md` local development pattern if present; otherwise phase docs style | partial |
## Pattern Assignments
### Shared DTO + Constants Files
Applies to:
- `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`
**Analog:** `shared/build.gradle.kts`, `shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt`, `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`, `shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt`
**Shared purity pattern** (`shared/build.gradle.kts` lines 16-20):
```kotlin
sourceSets {
commonMain.dependencies {
// Phase 1: intentionally empty. Domain models + DTOs land Phase 2+.
// D-19 / INFRA-06: No Ktor, Compose, SQLDelight, Koin, or Kermit here - EVER.
}
}
```
**Public constant pattern** (`shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt` lines 1-3):
```kotlin
package dev.ulfrx.recipe
public const val SERVER_PORT: Int = 8080
```
**Serializable DTO pattern** (`server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` lines 12, 19-22):
```kotlin
import kotlinx.serialization.Serializable
@Serializable
private data class Health(
val status: String,
)
```
**Test skeleton pattern** (`shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt` lines 1-10):
```kotlin
package dev.ulfrx.recipe
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}
```
**Planner note:** New shared symbols must be `public` because `shared` has `explicitApi()` enabled at `shared/build.gradle.kts` lines 9-10. Keep only Kotlin stdlib and `kotlinx.serialization` imports in shared DTOs.
### Gradle Catalog + Module Build Files
Applies to:
- `gradle/libs.versions.toml`
- `shared/build.gradle.kts`
- `server/build.gradle.kts`
- `composeApp/build.gradle.kts`
**Analog:** existing build files
**Version catalog organization** (`gradle/libs.versions.toml` lines 1-24, 27-43, 68-79):
```toml
[versions]
kotlin = "2.3.20"
kotlinx-serialization = "1.7.3"
ktor = "3.4.1"
[libraries]
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
ktor-serverCore = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
[plugins]
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
```
**Server dependency pattern** (`server/build.gradle.kts` lines 1-8, 27-39):
```kotlin
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.ktor)
alias(libs.plugins.flywayPlugin)
application
id("recipe.quality")
}
dependencies {
implementation(libs.ktor.serverCore)
implementation(libs.ktor.serverNetty)
implementation(libs.ktor.serverContentNegotiation)
implementation(libs.ktor.serializationKotlinxJson)
implementation(projects.shared)
testImplementation(libs.ktor.serverTestHost)
testImplementation(libs.kotlin.testJunit)
}
```
**Compose dependency pattern** (`composeApp/build.gradle.kts` lines 48-69):
```kotlin
sourceSets {
commonMain.dependencies {
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.composeViewmodel)
implementation(libs.kermit)
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.components.resources)
implementation(projects.shared)
}
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android)
}
}
```
**No version literal guard** (`tools/verify-no-version-literals.sh` lines 1-20):
```bash
VIOLATIONS=$(grep -rn -E 'version[[:space:]]*=[[:space:]]*"[0-9]' --include='*.gradle.kts' . 2>/dev/null \
| grep -v 'build-logic/build.gradle.kts' \
| grep -vE ':[0-9]+:version[[:space:]]*=[[:space:]]*"[0-9]' \
|| true)
```
**Planner note:** Put new dependency versions in `gradle/libs.versions.toml`; do not add library versions directly in `*.gradle.kts` except for explicitly justified test-only literals already called out by the plan.
### Client Koin + Logging + Auth Module
Applies to:
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt`
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`
- Auth singleton wiring in `AuthSession.kt`
**Analog:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`, `Koin.kt`, `Logging.kt`, platform bootstraps
**Koin module placeholder pattern** (`AppModule.kt` lines 1-9):
```kotlin
package dev.ulfrx.recipe.di
import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule =
module {
// intentionally empty in Phase 1
}
```
**Koin startup pattern** (`Koin.kt` lines 1-10):
```kotlin
package dev.ulfrx.recipe.di
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication =
startKoin {
config?.invoke(this)
modules(appModule)
}
```
**Client logging bootstrap** (`Logging.kt` lines 1-8):
```kotlin
package dev.ulfrx.recipe.logging
import co.touchlab.kermit.Logger
fun configureLogging() {
Logger.setTag("recipe")
// Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default.
}
```
**Platform init order** (`MainApplication.kt` lines 8-15, `KoinIos.kt` lines 5-8):
```kotlin
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
configureLogging()
initKoin {
androidContext(this@MainApplication)
}
}
}
```
```kotlin
fun doInitKoin() {
configureLogging()
initKoin()
}
```
**Planner note:** Add `authModule` without starting Koin from composables. Wire modules from the existing `initKoin` path, preserving the one-start-per-platform rule.
### Compose App Shell + Auth Screens
Applies to:
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`
- `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt`
- `SplashScreen.kt`
- `LoginScreen.kt`
- `LoginViewModel.kt`
- `PostLoginPlaceholderScreen.kt`
- `PostLoginViewModel.kt`
- `composeApp/src/commonMain/composeResources/values/strings.xml`
**Analog:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`, `composeResources/drawable/compose-multiplatform.xml`
**Current app wrapper to preserve/replace** (`App.kt` lines 25-52):
```kotlin
@Composable
@Preview
fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
}
}
}
}
```
**Resource import pattern** (`App.kt` lines 21-23):
```kotlin
import org.jetbrains.compose.resources.painterResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.compose_multiplatform
```
**Compose resource file placement analog** (`composeResources/drawable/compose-multiplatform.xml` lines 1-7):
```xml
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
```
**UI contract to copy from `02-UI-SPEC.md` lines 149-161:**
```text
AuthState.Loading -> SplashScreen()
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel())
AuthState.Authenticated(user, householdId) -> PostLoginPlaceholderScreen(user, viewModel = koinViewModel())
```
**Layout contract to copy from `02-UI-SPEC.md` lines 185-197:**
```kotlin
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.safeContentPadding()
.padding(horizontal = 16.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize(),
)
```
**Planner note:** Existing `App.kt` is template code. Keep the `@Composable`, `@Preview`, `MaterialTheme` shape, but replace the button/greeting body with the auth gate. All strings come from Compose Resources, not raw Polish literals.
### Server Application, Config, Flyway, and Routes
Applies to:
- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`
- `server/src/main/resources/application.conf`
- `server/src/main/resources/db/migration/V1__users.sql`
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt`
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt`
**Analog:** existing server files
**Application install and routing pattern** (`Application.kt` lines 24-38):
```kotlin
fun Application.module() {
install(ContentNegotiation) {
json()
}
Database.migrate(this)
configureRouting()
}
fun Application.configureRouting() {
routing {
get("/health") {
call.respond(Health(status = "ok"))
}
}
}
```
**HOCON env override pattern** (`application.conf` lines 1-18):
```hocon
ktor {
deployment {
port = 8080
port = ${?PORT}
}
}
database {
url = "jdbc:postgresql://localhost:5432/recipe"
url = ${?DATABASE_URL}
user = "recipe"
user = ${?DATABASE_USER}
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
```
**Flyway runtime pattern** (`Database.kt` lines 10-39):
```kotlin
fun migrate(app: Application) {
val url = app.environment.config.property("database.url").getString()
val user = app.environment.config.property("database.user").getString()
val password = app.environment.config.property("database.password").getString()
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
runCatching {
Flyway
.configure()
.dataSource(url, user, password)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.validateOnMigrate(true)
.cleanDisabled(true)
.load()
.migrate()
}.onFailure { ex ->
log.error("Flyway migration failed", ex)
throw IllegalStateException("Database unreachable or migration failed", ex)
}
}
```
**Planner note:** Insert auth install in `Application.module()` after `ContentNegotiation`/`CallLogging` and before protected routing. Keep `configureRouting()` testable without invoking real Postgres where possible.
### Server JWT Auth + Principal Resolver
Applies to:
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt`
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt`
**Analog:** `Application.kt` plugin install, `Database.kt` DB boundary. No existing JWT or Exposed analog exists yet.
**Plugin install style to copy** (`Application.kt` lines 24-27):
```kotlin
install(ContentNegotiation) {
json()
}
```
**DB config/logging boundary to copy** (`Database.kt` lines 7-9, 24-39):
```kotlin
object Database {
private val log = LoggerFactory.getLogger(Database::class.java)
fun migrate(app: Application) {
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
runCatching {
Flyway.configure().dataSource(url, user, password).load().migrate()
}.onFailure { ex ->
log.error("Flyway migration failed", ex)
throw IllegalStateException("Database unreachable or migration failed", ex)
}
}
}
```
**Required auth shape from `02-CONTEXT.md` lines 62-64:**
```kotlin
install(Authentication) {
jwt("authentik") {
// verifier(jwkProvider, issuer), withIssuer, withAudience, acceptLeeway(30)
// validate block must reject null or blank sub
}
}
```
**Required JIT upsert from `02-CONTEXT.md` lines 81-91:**
```sql
INSERT INTO users (sub, email, display_name)
VALUES (:sub, :email, :name)
ON CONFLICT (sub) DO UPDATE
SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
updated_at = now()
RETURNING *;
```
**Planner note:** Use Exposed DSL only and suspend transaction APIs for request-handling DB work. There is no local Exposed analog yet; the plan must treat `PrincipalResolver` as the first canonical server CRUD service.
### Server Tests
Applies to:
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtAuthTest.kt`
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt`
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` if added
**Analog:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt`
**Ktor test pattern** (`ApplicationTest.kt` lines 14-29):
```kotlin
class ApplicationTest {
@Test
fun `health endpoint returns 200 with status ok`() =
testApplication {
application {
install(ContentNegotiation) {
json()
}
configureRouting()
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
assertTrue(body.contains("\"status\""), "expected body to contain status field, was: $body")
assertTrue(body.contains("\"ok\""), "expected body to contain ok value, was: $body")
}
}
```
**Planner note:** Compose test modules directly with `testApplication { application { ... } }`. Avoid calling `Application.module()` in tests that should not require a real Postgres unless the test sets up an in-memory DB first.
### OIDC Platform Bootstrap
Applies to:
- `OidcClient.kt`
- `OidcResult.kt`
- platform `OidcClient.*.kt`
- `composeApp/src/androidMain/AndroidManifest.xml`
- `iosApp/iosApp/Info.plist`
- `iosApp/iosApp/iOSApp.swift`
- `iosApp/Podfile`
**Analog:** platform entry points and manifests
**Android activity pattern** (`MainActivity.kt` lines 10-19):
```kotlin
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
```
**Android manifest application/activity pattern** (`AndroidManifest.xml` lines 1-23):
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
```
**iOS SwiftUI bootstrap pattern** (`iOSApp.swift` lines 1-15):
```swift
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
init() {
KoinIosKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
```
**iOS plist pattern** (`Info.plist` lines 1-8):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>
```
**Target-specific main patterns** (`jvmMain/main.kt` lines 8-18, `webMain/main.kt` lines 8-14):
```kotlin
fun main() {
configureLogging()
initKoin()
application {
Window(
onCloseRequest = ::exitApplication,
title = "recipe",
) {
App()
}
}
}
```
```kotlin
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
configureLogging()
initKoin()
ComposeViewport {
App()
}
}
```
**Planner note:** Preserve existing Koin bootstrap while adding AppAuth callback wiring. For `recipe://callback`, register Android AppAuth receiver and iOS `CFBundleURLTypes`; do not replace unrelated app metadata.
### Client AuthSession, Token Store, HTTP Client, and Common Tests
Applies to:
- `AuthState.kt`
- `AuthSession.kt`
- `TokenStore.kt`
- `AuthHttpClient.kt`
- `MeClient.kt`
- `SettingsFactory.*.kt`
- `HttpClientEngine.*.kt`
- `AuthSessionTest.kt`
**Analog:** Koin module pattern, platform main files, `ComposeAppCommonTest.kt`. There is no existing Ktor client or settings storage analog.
**Common test skeleton** (`ComposeAppCommonTest.kt` lines 1-10):
```kotlin
package dev.ulfrx.recipe
import kotlin.test.Test
import kotlin.test.assertEquals
class ComposeAppCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}
```
**Koin module registration analog** (`AppModule.kt` lines 5-9):
```kotlin
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule =
module {
// intentionally empty in Phase 1
}
```
**Required Ktor client bearer shape from `02-RESEARCH.md` lines 313-329:**
```kotlin
install(Auth) {
bearer {
loadTokens {
authSession.currentBearerTokens()
}
refreshTokens {
authSession.refreshBearerTokens()
}
sendWithoutRequest { request ->
request.url.host == apiHost
}
}
}
```
**Required auth state shape from `02-CONTEXT.md` lines 97-107:**
```kotlin
sealed class AuthState {
data object Loading : AuthState()
data object Unauthenticated : AuthState()
data class Authenticated(
val user: User,
val householdId: HouseholdId? = null,
) : AuthState()
}
```
**Planner note:** This group becomes the client auth canonical pattern for later phases. Keep collaborators injectable behind small interfaces so common tests can fake OIDC and `/me` without platform AppAuth.
## Shared Patterns
### Shared Module Purity
**Source:** `tools/verify-shared-pure.sh` lines 1-15
**Apply to:** all files under `shared/src/commonMain`
```bash
# Enforces INFRA-06 / D-19: shared/commonMain must not import Ktor, Compose, SQLDelight.
# Runs grep against shared/src/commonMain/ only. Allowed imports: kotlin.*, kotlinx.serialization, kotlinx.datetime.
VIOLATIONS=$(grep -rn -E '^import[[:space:]]+(io\.ktor|androidx\.compose|org\.jetbrains\.compose|app\.cash\.sqldelight)' shared/src/commonMain/ 2>/dev/null || true)
```
### Server Startup Order
**Source:** `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` lines 24-30
**Apply to:** `Application.kt`, auth plugin install, route wiring
```kotlin
fun Application.module() {
install(ContentNegotiation) {
json()
}
Database.migrate(this)
configureRouting()
}
```
Phase 2 should extend this to:
```text
ContentNegotiation -> CallLogging redaction -> Database.migrate -> Database.connect -> configureAuth -> configureRouting
```
### Server Logging
**Source:** `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` lines 7-8, 24, 36-39
**Apply to:** server DB/auth services
```kotlin
object Database {
private val log = LoggerFactory.getLogger(Database::class.java)
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
log.error("Flyway migration failed", ex)
throw IllegalStateException("Database unreachable or migration failed", ex)
}
```
### Client Logging
**Source:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/logging/Logging.kt` lines 1-8
**Apply to:** `AuthSession`, OIDC client wrappers
```kotlin
import co.touchlab.kermit.Logger
fun configureLogging() {
Logger.setTag("recipe")
// Platform log writers (OSLog iOS, LogCat Android, System.out JVM/Wasm) install by default.
}
```
Use `Logger.withTag("auth")` for auth flow diagnostics, but never log token bodies or `Authorization` headers.
### Koin Startup
**Source:** `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt` lines 7-10 and platform callers
**Apply to:** `authModule`, ViewModels, OIDC clients, settings factories
```kotlin
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication =
startKoin {
config?.invoke(this)
modules(appModule)
}
```
### HOCON Env Vars
**Source:** `server/src/main/resources/application.conf` lines 11-18
**Apply to:** `oidc.issuer`, `oidc.audience`, `oidc.jwksUrl`, `oidc.leewaySeconds`
```hocon
database {
url = "jdbc:postgresql://localhost:5432/recipe"
url = ${?DATABASE_URL}
user = "recipe"
user = ${?DATABASE_USER}
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
```
### Ktor Route Tests
**Source:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` lines 17-29
**Apply to:** `JwtAuthTest`, `MeRouteTest`
```kotlin
testApplication {
application {
install(ContentNegotiation) {
json()
}
configureRouting()
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
}
```
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` | model | CRUD | No Exposed table exists yet. Phase 2 establishes first server table DSL pattern. |
| `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` JWT details | middleware | request-response | Ktor server exists, but no Authentication/JWT plugin exists yet. Use `02-CONTEXT.md` D-21/D-22 and Ktor docs from research. |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` | service | request-response | No Ktor client code exists yet. Use `02-RESEARCH.md` Ktor bearer shape. |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/TokenStore.kt` | service | file-I/O | No multiplatform-settings usage exists yet. Keep explicit platform store seam. |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt` | controller | event-driven | Koin ViewModel deps exist, but no ViewModel classes exist yet. Use method-per-action `StateFlow` convention from project docs/UI spec. |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt` | controller | event-driven | Same as LoginViewModel. |
| `iosApp/Podfile` | config | build-config | No existing Podfile. Follow Plan 03 CocoaPods DSL and iOS deployment target from `composeApp/build.gradle.kts`. |
## Metadata
**Analog search scope:** `composeApp/`, `server/`, `shared/`, `iosApp/`, `build-logic/`, `gradle/`, `tools/`, Phase 1 summaries and Phase 2 plan drafts.
**Files scanned:** 75+ code/config/planning files via `rg --files`, `find`, and targeted `nl -ba` reads.
**Pattern extraction date:** 2026-04-27

View File

@@ -0,0 +1,496 @@
# Phase 2: Authentication Foundation - Research
**Researched:** 2026-04-27
**Domain:** KMP native OIDC, secure token storage, Ktor JWT validation, Exposed/Flyway JIT users
**Confidence:** MEDIUM-HIGH
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
The following locked decisions are copied from `.planning/phases/02-authentication-foundation/02-CONTEXT.md` and are authoritative for planning. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- **D-01:** AppAuth on both mobile platforms. iOS uses AppAuth-iOS via CocoaPod added to `iosApp/Podfile`; Android uses AppAuth-Android (`net.openid:appauth`). Symmetric `expect class OidcClient` in `composeApp/commonMain/.../auth/`, with `actual` impls in `iosMain` and `androidMain` wrapping each platform's AppAuth. Uses AppAuth's `OIDAuthState` / `AuthState` as the in-memory session shape behind the seam.
- **D-02:** JVM Desktop actual is a dev-mode `DEV_AUTH_TOKEN` stub.
- **D-03:** Wasm actual is `NotImplementedError("Wasm OIDC: v2")`.
- **D-04:** `OidcClient.login()` and `.refresh()` are suspend functions bridged with `suspendCancellableCoroutine`.
- **D-05:** Authentik provider is Public + PKCE S256.
- **D-06:** Requested scopes are `openid profile email offline_access`.
- **D-07:** `aud` claim shape is pinned to a single string equal to `client_id`.
- **D-08:** Signing algorithm is RS256.
- **D-09:** Redirect URI is custom scheme `recipe://callback`.
- **D-10:** `docs/authentik-setup.md` is a Phase 2 deliverable.
- **D-11:** Client OIDC config is hardcoded in `shared/commonMain/Constants.kt`.
- **D-12:** Server OIDC config is via env vars in `application.conf`.
- **D-13:** Persist full AppAuth `AuthState` JSON blob via a secure settings abstraction.
- **D-14:** iOS Keychain accessibility target is `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`.
- **D-15:** One AuthState blob per app install.
- **D-16:** Proactive refresh uses AppAuth `performActionWithFreshTokens`.
- **D-17:** Reactive fallback uses Ktor client `Auth { bearer { refreshTokens { ... } } }`.
- **D-18:** Refresh failure silently transitions to unauthenticated.
- **D-19:** Logout calls Authentik end-session and deletes persisted AuthState.
- **D-20:** AppAuth end-session APIs drive logout on both mobile platforms.
- **D-21:** Ktor installs `jwt("authentik")` with issuer, audience, 30-second leeway, and `sub` validation.
- **D-22:** JWKS provider uses cache size 10, 15-minute TTL, and 10/minute rate limit.
- **D-23:** Never log tokens or `Authorization` headers.
- **D-24:** Phase 2 ships `V1__users.sql`.
- **D-25:** JIT provisioning upserts by OIDC `sub` and updates email/display name on each authenticated request.
- **D-26:** Exposed DSL only; every coroutine-touching DB call uses the suspend transaction API.
- **D-27:** Protected `GET /api/v1/me` returns `MeResponse`.
- **D-28:** Client auth state is `Loading | Unauthenticated | Authenticated(user, householdId = null)`.
- **D-29:** `AuthSession` is a Koin singleton in `authModule`.
- **D-30:** `App()` gates between loading, login, and post-login placeholder.
- **D-31:** Login screen is minimal.
- **D-32:** Login errors render inline below the button.
- **D-33:** Post-login placeholder says `Witaj, {displayName}!` and includes `Wyloguj się`.
- **D-34:** User-facing auth strings use Compose Resources from day 1.
### Claude's Discretion
Copied from CONTEXT.md; planner may choose within these boundaries. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- Exact `Koin` `authModule` definition style.
- Ktor Client bearer auth boilerplate, including `refreshTokens`, token loader, and `sendWithoutRequest`.
- Whether `MeResponse` DTO and `User` domain model are the same type or separate.
- Concrete UUID type, choosing what pairs cleanly with Exposed UUID columns and `kotlinx.serialization`.
- Whether AppAuth-iOS is added via Gradle CocoaPods DSL or hand-written `iosApp/Podfile`.
- Splash placeholder visual.
- Whether `OIDC_ISSUER` ends with a trailing slash; pin and document the choice.
- Logger tag/level for AppAuth events.
### Deferred Ideas (OUT OF SCOPE)
Copied from CONTEXT.md; planner must not include these in Phase 2. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- Universal Links / App Links.
- BuildConfig-style Gradle injection of OIDC config.
- Real Desktop OIDC.
- Wasm OIDC implementation.
- Two-tier logout.
- Background token refresh.
- Apple Sign-in as a first-class button.
- Per-user persisted `AuthState`.
- Modal/toast for refresh-failure UX.
- Authentik provisioning automation.
- JWT validation tests against a real Authentik instance.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| AUTH-01 | User signs in through Authentik with authorization code + PKCE. [VERIFIED: `.planning/REQUIREMENTS.md`] | AppAuth native flows, redirect registration, Authentik public provider + PKCE S256. [CITED: https://cocoapods.org/pods/AppAuth] |
| AUTH-02 | Client stores access + refresh tokens securely. [VERIFIED: `.planning/REQUIREMENTS.md`] | Persist AppAuth state JSON, but use explicit secure platform storage; no-arg multiplatform-settings is not enough on Android. [CITED: https://github.com/russhwolf/multiplatform-settings] |
| AUTH-03 | Ktor validates access tokens via Authentik JWKS. [VERIFIED: `.planning/REQUIREMENTS.md`] | Use Ktor JWT provider with issuer, audience, signature/JWKS, leeway, and validate block. [CITED: https://ktor.io/docs/server-jwt.html] |
| AUTH-04 | Session persists across launches via refresh. [VERIFIED: `.planning/REQUIREMENTS.md`] | Restore AppAuth AuthState JSON and call `performActionWithFreshTokens`; request `offline_access`. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
| AUTH-05 | User can sign out and return to login screen. [VERIFIED: `.planning/REQUIREMENTS.md`] | Use Authentik end-session endpoint and AppAuth end-session request, then wipe local state. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/] |
| AUTH-06 | Users are JIT-provisioned by OIDC `sub`. [VERIFIED: `.planning/REQUIREMENTS.md`] | Flyway users table plus Exposed DSL upsert in suspend transaction. [CITED: https://www.jetbrains.com/help/exposed/dsl-crud-operations.html] |
</phase_requirements>
## Summary
Phase 2 should be planned as three cooperating auth boundaries: native client OIDC, server token validation, and server principal provisioning. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] AppAuth is the right non-hand-rolled primitive because both Android and iOS expose an auth-state object that refreshes tokens and serializes/restores state. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] [CITED: https://openid.github.io/AppAuth-iOS/docs/latest/interface_o_i_d_auth_state.html]
The main planning correction is token storage. [VERIFIED: web docs] `multiplatform-settings` supports Apple `KeychainSettings`, but its no-arg/default Android path delegates to ordinary SharedPreferences and its README says no-arg does not provide encrypted implementations. [CITED: https://github.com/russhwolf/multiplatform-settings] Android `EncryptedSharedPreferences` still exists and encrypts preferences, but AndroidX Security Crypto 1.1.0 deprecated its crypto APIs in favor of platform APIs/direct Android Keystore use. [CITED: https://developer.android.com/jetpack/androidx/releases/security] Plan Phase 2 with an explicit Wave 0 decision/task for Android secure storage instead of assuming a secure adapter exists. [VERIFIED: docs comparison]
**Primary recommendation:** Use AppAuth AuthState as the session source of truth, store the serialized state behind an explicit `SecureAuthStateStore` expect/actual, protect `/api/v1/me` with Ktor `jwt("authentik")`, and JIT-upsert `users` by `sub` in a suspend Exposed transaction. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] [CITED: https://ktor.io/docs/server-jwt.html]
## Project Constraints (from CLAUDE.md)
- Use GSD planning artifacts as source of truth before implementation. [VERIFIED: `CLAUDE.md`]
- Keep Phase 2 within current roadmap order: Phase 1 complete, Phase 2 auth next. [VERIFIED: `.planning/STATE.md`]
- Client is KMP + Compose Multiplatform; server is Ktor 3.x + Postgres + Exposed DSL + Flyway. [VERIFIED: `CLAUDE.md`]
- `shared/commonMain` may contain domain models and API DTOs only; no UI, HTTP, DB, Koin, Kermit, AppAuth, or settings imports. [VERIFIED: `CLAUDE.md`]
- Exposed DAO is forbidden; use DSL only. [VERIFIED: `CLAUDE.md`]
- Coroutine-touching Ktor handlers must use Exposed suspend transactions, not blocking `transaction {}`. [VERIFIED: `CLAUDE.md`] [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
- All user-facing strings must be externalized from day 1. [VERIFIED: `CLAUDE.md`]
- Never log bearer tokens or authorization headers. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|--------------|----------------|-----------|
| OIDC browser login + callback | Browser / Client | Authentik | Native app owns browser launch and redirect handling; Authentik owns identity UI and token issuance. [CITED: https://cocoapods.org/pods/AppAuth] |
| Token refresh | Browser / Client | Authentik | AppAuth owns fresh-token behavior; Authentik token endpoint issues replacements. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
| Secure token persistence | Browser / Client | OS secure storage | Client owns persistence; storage must be Keychain/Android secure storage, not server. [VERIFIED: `.planning/REQUIREMENTS.md`] |
| Bearer attachment to API calls | Browser / Client | API / Backend | Ktor Client attaches bearer tokens; backend rejects invalid tokens. [CITED: https://ktor.io/docs/client-bearer-auth.html] |
| JWT signature/claim validation | API / Backend | Authentik JWKS | Ktor validates issuer/audience/signature/expiry; Authentik exposes JWKS. [CITED: https://ktor.io/docs/server-jwt.html] [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/] |
| JIT user provisioning | API / Backend | Database / Storage | Backend derives user from JWT claims and owns DB upsert. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
| `/api/v1/me` | API / Backend | shared DTO | Route returns authenticated user DTO after provisioning. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
| Logout | Browser / Client | Authentik | Client initiates end-session and deletes local state. [CITED: https://openid.github.io/AppAuth-iOS/docs/latest/interface_o_i_d_end_session_request.html] |
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `net.openid:appauth` | 0.11.1 | Android OIDC/OAuth native authorization flow and AuthState. [VERIFIED: Maven Central search] | AppAuth provides PKCE-compatible native browser flow, token refresh helpers, and JSON AuthState. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
| CocoaPod `AppAuth` | 2.0.0 | iOS OIDC/OAuth native authorization flow. [VERIFIED: CocoaPods] | AppAuth maps OAuth/OIDC flows and supports fresh-token helpers and native browser/user-agent patterns. [CITED: https://cocoapods.org/pods/AppAuth] |
| Ktor auth client/server artifacts | Project catalog 3.4.1; current release observed 3.4.3 | Client bearer retry and server JWT validation. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search] | Ktor docs expose `loadTokens`, `refreshTokens`, single-flight refresh, JWT verifier, JWKS provider, and validate/challenge hooks. [CITED: https://ktor.io/docs/client-bearer-auth.html] [CITED: https://ktor.io/docs/server-jwt.html] |
| `com.russhwolf:multiplatform-settings` | 1.3.0 | Common key-value API over platform delegates. [VERIFIED: Maven Central] | Useful interface for `SecureAuthStateStore`; must not use no-arg/default Android for secrets. [CITED: https://github.com/russhwolf/multiplatform-settings] |
| Exposed DSL | 1.2.0 current | Server SQL DSL and suspend transactions. [VERIFIED: Exposed docs] | Official docs show DSL CRUD/upsert and suspend transaction APIs. [CITED: https://www.jetbrains.com/help/exposed/dsl-crud-operations.html] |
| Flyway | Project catalog 12.4.0 | `V1__users.sql` migration. [VERIFIED: `gradle/libs.versions.toml`] | Existing Phase 1 server bootstrap already runs Flyway on startup. [VERIFIED: `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt`] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `androidx.security:security-crypto` | 1.1.0 stable | Android encrypted SharedPreferences option. [CITED: https://developer.android.com/jetpack/androidx/releases/security] | Use only if accepting deprecation; otherwise implement Android Keystore-backed store and flag decision. [CITED: https://developer.android.com/reference/kotlin/androidx/security/crypto/EncryptedSharedPreferences] |
| `com.auth0:jwks-rsa` | Transitive/API used by Ktor examples | JWKS provider cache/rate limit builder. [CITED: https://ktor.io/docs/server-jwt.html] | Add explicit alias if Ktor JWT artifact does not expose the builder cleanly. [ASSUMED] |
| `kotlinx-serialization-json` | Already via Ktor serialization artifact | DTO JSON and AuthState wrapper metadata if needed. [VERIFIED: `gradle/libs.versions.toml`] | Keep DTOs in `shared`; keep AppAuth JSON as opaque string in client. [VERIFIED: `CLAUDE.md`] |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| AppAuth native clients | Hand-rolled authorization-code flow | Reject: PKCE, redirect state, token exchange, refresh, cancellation, and end-session are deceptively complex. [CITED: https://cocoapods.org/pods/AppAuth] |
| `multiplatform-settings` no-arg | Explicit expect/actual store | Use explicit store: no-arg defaults are not encrypted and are hard to substitute in tests. [CITED: https://github.com/russhwolf/multiplatform-settings] |
| Exposed DAO | Exposed DSL | Reject: project forbids DAO; DSL better matches JSON/upsert and transaction control. [VERIFIED: `CLAUDE.md`] |
**Installation:**
```bash
# Add aliases in gradle/libs.versions.toml; use the project's version catalog.
# Ktor artifacts should share the existing ktor version ref unless a deliberate full Ktor bump is planned.
```
**Version verification:** Ktor current docs show 3.4.3 and Maven search observed 3.4.3 on 2026-04-22; the repo currently pins 3.4.1. [CITED: https://ktor.io/docs/server-jwt.html] [VERIFIED: Maven search] Multiplatform Settings current is 1.3.0. [CITED: https://central.sonatype.com/artifact/com.russhwolf/multiplatform-settings] AppAuth-iOS current CocoaPod is 2.0.0. [CITED: https://cocoapods.org/pods/AppAuth] AppAuth-Android current Maven version remains 0.11.1. [VERIFIED: Maven Central search]
## Architecture Patterns
### System Architecture Diagram
```text
User taps "Zaloguj się"
-> Compose LoginScreen
-> AuthSession.login()
-> OidcClient actual (Android/iOS AppAuth)
-> Authentik authorization endpoint (system browser, PKCE, state)
-> recipe://callback
-> AppAuth token exchange
-> AuthState JSON persisted via SecureAuthStateStore
-> AuthSession calls GET /api/v1/me with fresh access token
-> Ktor jwt("authentik") verifier
-> Authentik JWKS cache/rate limit
-> validate issuer + audience + expiry + sub
-> PrincipalResolver upserts users by sub
-> /api/v1/me returns MeResponse
-> AuthSession emits Authenticated(user, householdId = null)
Logout:
User taps "Wyloguj się"
-> AppAuth EndSessionRequest / Authentik end-session endpoint
-> local AuthState blob removed
-> AuthSession emits Unauthenticated
```
### Recommended Project Structure
```text
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/
├── auth/ # AuthSession, AuthState, OidcClient expect, SecureAuthStateStore expect
├── data/remote/ # HttpClient factory, AuthApi for /api/v1/me
├── di/ # authModule added to appModule composition
└── ui/screens/auth/ # LoginScreen, PostLoginPlaceholder
composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/
└── OidcClient.android.kt # AppAuth-Android + redirect support + secure store actual
composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/
└── OidcClient.ios.kt # AppAuth-iOS CocoaPod bindings + secure store actual
server/src/main/kotlin/dev/ulfrx/recipe/
├── auth/ # AuthConfig, configureAuthentication, PrincipalResolver, AuthPrincipal
├── db/tables/ # Users table
└── routes/ # me route
shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/
└── MeResponse.kt # Serializable DTO only
```
### Pattern 1: AuthState Is Opaque Session Storage
**What:** Store the platform AppAuth AuthState JSON string as one opaque blob and keep DTO/domain mapping outside the blob. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html]
**When to use:** Always for mobile token persistence in Phase 2. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
**Example:**
```kotlin
interface SecureAuthStateStore {
fun readAuthStateJson(): String?
fun writeAuthStateJson(value: String)
fun clear()
}
```
### Pattern 2: Fresh Token Wrapper Before Ktor Calls
**What:** `AuthSession.getAccessToken()` calls AppAuth `performActionWithFreshTokens`, persists any updated state, and returns a token to Ktor. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html]
**When to use:** Before every authenticated API call, especially `/api/v1/me`. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
**Ktor fallback:** Configure `refreshTokens {}` for 401 retries and rely on Ktor's documented single-flight refresh behavior. [CITED: https://ktor.io/docs/client-bearer-auth.html]
### Pattern 3: JWT Validation Then Principal Resolution
**What:** Ktor JWT authenticates claims; a resolver maps JWT `sub` to a persisted `users` row. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
**When to use:** Every protected route, starting with `/api/v1/me`. [VERIFIED: `.planning/ROADMAP.md`]
**Example:**
```kotlin
install(Authentication) {
jwt("authentik") {
realm = "recipe"
verifier(jwkProvider, issuer) {
withIssuer(issuer)
withAudience(audience)
acceptLeeway(30)
}
validate { credential ->
credential.payload.subject?.takeIf { it.isNotBlank() }?.let { JWTPrincipal(credential.payload) }
}
}
}
```
Source: Ktor JWT docs show dependencies, JWKS verifier, `acceptLeeway`, and required `validate`. [CITED: https://ktor.io/docs/server-jwt.html]
### Anti-Patterns to Avoid
- **Using no-arg `Settings()` for refresh tokens:** It defaults to non-encrypted stores on some platforms; use explicit secure actuals. [CITED: https://github.com/russhwolf/multiplatform-settings]
- **Trusting access token alone for user creation:** Use `sub` as stable identity and update email/name as mutable claims. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- **Blocking `transaction {}` inside Ktor suspend routes:** Current Exposed docs warn synchronous transactions block the current thread; use suspend transactions for coroutine paths. [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
- **Logging token-bearing headers:** Bearer tokens grant API access; project explicitly forbids token logging. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Native OAuth/OIDC browser flow | Custom URL construction + manual token exchange | AppAuth Android/iOS | Handles PKCE/state/native user agents and token refresh helpers. [CITED: https://cocoapods.org/pods/AppAuth] |
| JWT parsing/verification | Manual JWT decode or static public key | Ktor `ktor-server-auth-jwt` + JWKS provider | Handles signature verification and Ktor principal integration. [CITED: https://ktor.io/docs/server-jwt.html] |
| Token retry machinery | Custom 401 retry queue | Ktor Client Auth bearer provider | Ktor documents automatic refresh and single-flight behavior. [CITED: https://ktor.io/docs/client-bearer-auth.html] |
| User provisioning race handling | Select-then-insert | Postgres/Exposed upsert | Atomic upsert avoids duplicate rows under concurrent first requests. [CITED: https://www.jetbrains.com/help/exposed/dsl-crud-operations.html] |
| Android crypto primitives | Custom encryption without review | Android Keystore-backed approach or accepted Security Crypto dependency | AndroidX deprecated Security Crypto in favor of platform APIs/direct Keystore. [CITED: https://developer.android.com/jetpack/androidx/releases/security] |
**Key insight:** The auth surface is mostly protocol glue; bugs are usually in edge handling (redirect byte-match, refresh races, JWKS rotation, secure persistence), so the plan should compose proven libraries and test edge cases. [VERIFIED: `.planning/research/PITFALLS.md`] [CITED: https://ktor.io/docs/client-bearer-auth.html]
## Common Pitfalls
### Pitfall 1: Assuming Multiplatform Settings Gives Secure Android Storage
**What goes wrong:** Refresh tokens land in ordinary SharedPreferences. [CITED: https://github.com/russhwolf/multiplatform-settings]
**Why it happens:** The no-arg module favors convenience and documents that it does not provide encrypted implementations. [CITED: https://github.com/russhwolf/multiplatform-settings]
**How to avoid:** Create `SecureAuthStateStore` with platform actuals; iOS uses Keychain, Android uses an explicitly chosen secure implementation. [VERIFIED: docs comparison]
**Warning signs:** `Settings()` appears in auth storage code or Android store is `SharedPreferencesSettings` over default prefs. [CITED: https://github.com/russhwolf/multiplatform-settings]
### Pitfall 2: Authentik Refresh Token Missing
**What goes wrong:** Login works but session does not survive access-token expiry or app relaunch. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/create-oauth2-provider/]
**Why it happens:** Authentik requires `offline_access` request and provider scope mapping to issue refresh tokens. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/create-oauth2-provider/]
**How to avoid:** Provider config doc must include `offline_access` scope mapping and app request must include `offline_access`. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
### Pitfall 3: JWKS / Audience / Issuer Drift
**What goes wrong:** Valid-looking tokens return 401, especially after config changes or key rotation. [VERIFIED: `.planning/research/PITFALLS.md`]
**Why it happens:** Ktor requires explicit verifier and validate block; Authentik provider config controls signing and JWKS endpoint. [CITED: https://ktor.io/docs/server-jwt.html] [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/]
**How to avoid:** Pin issuer, audience, RS256 signing key, JWKS URL, cache TTL, and wrong-audience tests. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
### Pitfall 4: Exposed API Drift
**What goes wrong:** Planner writes tasks using old `newSuspendedTransaction` imports but current Exposed docs show `suspendTransaction` in `org.jetbrains.exposed.v1.*`. [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
**Why it happens:** Exposed 1.x introduced package/API changes. [CITED: https://www.jetbrains.com/help/exposed/breaking-changes.html]
**How to avoid:** Before implementation, pin Exposed version and verify the exact suspend transaction import via Gradle dependency sources. [VERIFIED: docs comparison]
## Code Examples
### Authentik Provider Checklist
```text
Provider type: OAuth2/OIDC Public client
Flow: authorization code with PKCE S256
Redirect URI: recipe://callback
Scopes: openid profile email offline_access
Audience: single string = client_id
Signing: asymmetric RS256 signing key, JWKS endpoint documented
Logout: end-session endpoint documented
```
Sources: Authentik OAuth docs and Phase 2 context. [CITED: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/] [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
### Ktor Client Bearer Shape
```kotlin
install(Auth) {
bearer {
loadTokens {
authSession.currentBearerTokens()
}
refreshTokens {
authSession.refreshBearerTokens()
}
sendWithoutRequest { request ->
request.url.host == apiHost
}
}
}
```
Source: Ktor client bearer docs. [CITED: https://ktor.io/docs/client-bearer-auth.html]
### Users Migration
```sql
CREATE TABLE 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()
);
CREATE INDEX users_sub_idx ON users(sub);
```
Source: Phase 2 context. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Hand-rolled mobile OAuth redirects | AppAuth native libraries | Stable native-app OAuth guidance; AppAuth-iOS 2.0.0 current | Use AppAuth on both mobile targets. [CITED: https://cocoapods.org/pods/AppAuth] |
| AppAuth-iOS 1.x | AppAuth-iOS 2.0.0 | Latest CocoaPod released Apr 2025 | Check iOS minimum support; release notes say 2.0.0 raises minimum iOS to 12. [CITED: https://github.com/openid/AppAuth-iOS/releases] |
| Ktor 3.4.1 in repo | Ktor 3.4.3 current docs/release | 2026-04-22 | Prefer same catalog ref for new auth artifacts; consider full Ktor patch bump as a separate task. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search] |
| Exposed `newSuspendedTransaction` examples | Exposed 1.2 docs show `suspendTransaction` under `org.jetbrains.exposed.v1.*` | Exposed 1.x | Verify exact import during planning. [CITED: https://www.jetbrains.com/help/exposed/transactions.html] |
| AndroidX Security Crypto as preferred encrypted prefs | AndroidX deprecated all Security Crypto APIs in favor of platform APIs/direct Keystore | 1.1.0-alpha07 / 1.1.0 | Planner must explicitly choose Android storage path. [CITED: https://developer.android.com/jetpack/androidx/releases/security] |
**Deprecated/outdated:**
- Treating `multiplatform-settings-no-arg` as secure storage is wrong for Phase 2 secrets. [CITED: https://github.com/russhwolf/multiplatform-settings]
- Treating Android `EncryptedSharedPreferences` as unproblematic current best practice is outdated; it is available but deprecated. [CITED: https://developer.android.com/reference/kotlin/androidx/security/crypto/EncryptedSharedPreferences]
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `com.auth0:jwks-rsa` may need an explicit alias if Ktor's auth JWT dependency does not expose the builder cleanly. | Standard Stack | Minor Gradle dependency task may be missing. |
## Open Questions (RESOLVED)
1. **RESOLVED — Android secure token storage final choice**
- What we know: no-arg/default multiplatform-settings is not encrypted on Android; AndroidX Security Crypto encrypts preferences but is deprecated. [CITED: https://github.com/russhwolf/multiplatform-settings] [CITED: https://developer.android.com/jetpack/androidx/releases/security]
- Decision from PLAN.md: Phase 2 uses AndroidX Security Crypto `EncryptedSharedPreferences` behind an explicit `SecureAuthStateStore.android.kt` implementation because the project requirement/context explicitly calls out EncryptedSharedPreferences for Android token storage. The deprecation risk is contained behind the `SecureAuthStateStore` seam so a future Android Keystore-backed implementation can replace it without touching `AuthSession`.
- Guardrail: auth code must not use no-arg `Settings()` or ordinary `SharedPreferences` for tokens; Plan 03 includes grep-verifiable acceptance criteria for this.
2. **RESOLVED — Exposed version and suspend transaction import**
- What we know: current Exposed docs use `suspendTransaction`; project context says `newSuspendedTransaction`. [CITED: https://www.jetbrains.com/help/exposed/transactions.html] [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- Decision from PLAN.md: Plan 02 verifies the exact suspend transaction API/import against the pinned Exposed dependency before implementing DB code. Expected for the current catalog path is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, execution must use that exact import and record the choice in `02-02-SUMMARY.md`.
- Guardrail: no blocking `transaction {}` inside suspend route code.
3. **RESOLVED — Ktor patch bump**
- What we know: repo pins 3.4.1 and current docs/release search show 3.4.3. [VERIFIED: `gradle/libs.versions.toml`] [VERIFIED: Maven search]
- Decision from PLAN.md: Phase 2 keeps Ktor pinned to the existing catalog version (`3.4.1`) and adds auth artifacts against that same version ref. A patch bump is deferred unless implementation reveals a concrete incompatibility.
- Guardrail: do not opportunistically bump Ktor during auth implementation without an explicit failing command or compatibility reason.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|-------------|-----------|---------|----------|
| Java | Gradle/Kotlin build | yes | OpenJDK 25.0.2 | Gradle toolchains may download/use configured JDKs. [VERIFIED: `java -version`] |
| Gradle wrapper | Build/test | yes | 9.4.1 | None needed. [VERIFIED: `./gradlew --version`] |
| Xcode | iOS build/callback wiring | yes | Xcode 26.2 | None for iOS UAT. [VERIFIED: `xcodebuild -version`] |
| CocoaPods | AppAuth-iOS integration | yes | 1.16.2 | Swift Package/manual Podfile possible but not preferred for KMP CocoaPods DSL. [VERIFIED: `pod --version`] |
| Docker | Postgres/test services | yes | 27.3.1 | Use local Postgres if Docker unavailable. [VERIFIED: `docker --version`] |
| psql | Manual DB inspection | no | — | Use Docker exec or server tests. [VERIFIED: `command -v psql`] |
| Android Debug Bridge | Android manual UAT | no | — | Android manual UAT may need Android Studio/SDK install; iOS remains primary. [VERIFIED: `command -v adb`] |
| OpenSSL | JWT/test key generation support | yes | 3.4.1 | JVM crypto APIs can generate test keys. [VERIFIED: `openssl version`] |
**Missing dependencies with no fallback:** none for research/planning. [VERIFIED: environment audit]
**Missing dependencies with fallback:** `psql` and `adb` are missing; planner should not depend on them for automated Phase 2 gates. [VERIFIED: environment audit]
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | `kotlin.test` + JUnit for server; KMP common tests for auth state/store seams. [VERIFIED: `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt`] |
| Config file | Existing Gradle/KMP test setup; no standalone test config. [VERIFIED: repo scan] |
| Quick run command | `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest` [VERIFIED: Phase 1 validation pattern] |
| Full suite command | `./gradlew check` [VERIFIED: Phase 1 validation pattern] |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|--------------|
| AUTH-01 | OIDC request config includes issuer/client/redirect/scopes and mobile actuals compile | unit/build + manual iOS UAT | `./gradlew :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileDebugKotlinAndroid` | no, Wave 0 |
| AUTH-02 | AuthState JSON store writes/reads/clears and avoids no-arg insecure store for auth | common unit + grep invariant | `./gradlew :composeApp:jvmTest` plus grep for `Settings()` in auth store | no, Wave 0 |
| AUTH-03 | `/api/v1/me` rejects missing, expired, wrong-audience tokens and accepts valid test JWT | server integration | `./gradlew :server:test --tests "*Auth*"` | no, Wave 0 |
| AUTH-04 | Restored persisted AuthState refreshes token before `/me` | common/platform-stub unit | `./gradlew :composeApp:jvmTest` | no, Wave 0 |
| AUTH-05 | Logout calls end-session path when possible and clears local AuthState | unit + manual iOS UAT | `./gradlew :composeApp:jvmTest` | no, Wave 0 |
| AUTH-06 | First authenticated `/me` creates/updates user by `sub` | server integration with test DB or mocked transaction seam | `./gradlew :server:test --tests "*Me*"` | no, Wave 0 |
### Sampling Rate
- **Per task commit:** `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest` [VERIFIED: Phase 1 validation pattern]
- **Per wave merge:** `./gradlew check` [VERIFIED: Phase 1 validation pattern]
- **Phase gate:** full suite green plus manual iOS Authentik login/logout UAT. [VERIFIED: `.planning/ROADMAP.md`]
### Wave 0 Gaps
- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/AuthJwtTest.kt` — covers valid/missing/expired/wrong-audience JWT cases. [VERIFIED: repo scan]
- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/MeRouteTest.kt` — covers JIT provisioning and `/api/v1/me`. [VERIFIED: repo scan]
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` — covers state transitions and refresh failure behavior. [VERIFIED: repo scan]
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreTest.kt` — covers read/write/clear contract with fake store. [VERIFIED: repo scan]
- [ ] Android/iOS manual UAT checklist in `docs/authentik-setup.md`. [VERIFIED: repo scan]
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|------------------|
| V2 Authentication | yes | Authentik OIDC authorization code + PKCE through AppAuth. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
| V3 Session Management | yes | Secure AuthState persistence, AppAuth refresh, logout clears local state and calls end-session. [CITED: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html] |
| V4 Access Control | yes | JWT-protected `/api/v1/me`; household access control waits for Phase 3. [VERIFIED: `.planning/ROADMAP.md`] |
| V5 Input Validation | yes | Validate JWT claims (`sub`, issuer, audience, expiry); validate route authentication before response. [CITED: https://ktor.io/docs/server-jwt.html] |
| V6 Cryptography | yes | Use AppAuth/JWKS/OS secure storage; do not hand-roll protocol crypto. [CITED: https://cocoapods.org/pods/AppAuth] |
### Known Threat Patterns for KMP/Ktor OIDC
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Authorization-code interception via custom scheme | Spoofing / Elevation | Public client + PKCE S256 + AppAuth state handling. [CITED: https://cocoapods.org/pods/AppAuth] |
| Token leakage in logs | Information Disclosure | Redact Authorization header and never log token bodies. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`] |
| Wrong-audience token accepted | Elevation | `.withAudience(clientId)` and wrong-audience test. [CITED: https://ktor.io/docs/server-jwt.html] |
| JWKS key rotation denial | Denial of Service | JWKS cache with bounded TTL and rate limiting. [CITED: https://ktor.io/docs/server-jwt.html] |
| Refresh token stored in plaintext | Information Disclosure | Explicit secure platform actuals; reject no-arg settings for auth secrets. [CITED: https://github.com/russhwolf/multiplatform-settings] |
## Sources
### Primary (HIGH confidence)
- `.planning/phases/02-authentication-foundation/02-CONTEXT.md` — phase decisions and boundaries.
- `.planning/PROJECT.md`, `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` — product and phase scope.
- `CLAUDE.md` / `AGENTS.md` — project constraints.
- Ktor JWT docs: https://ktor.io/docs/server-jwt.html
- Ktor client bearer docs: https://ktor.io/docs/client-bearer-auth.html
- AppAuth Android AuthState docs: https://openid.github.io/AppAuth-Android/docs/latest/net/openid/appauth/AuthState.html
- AppAuth iOS AuthState docs: https://openid.github.io/AppAuth-iOS/docs/latest/interface_o_i_d_auth_state.html
- Authentik OAuth2 provider docs: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/
- Authentik create provider docs: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/create-oauth2-provider/
- Multiplatform Settings README: https://github.com/russhwolf/multiplatform-settings
- AndroidX Security Crypto docs: https://developer.android.com/jetpack/androidx/releases/security
- Exposed transactions/docs: https://www.jetbrains.com/help/exposed/transactions.html
### Secondary (MEDIUM confidence)
- Maven/CocoaPods registry search results for latest versions.
- Existing Phase 1 summaries and validation artifacts under `.planning/phases/01-project-infrastructure-module-wiring/`.
### Tertiary (LOW confidence)
- A1 about needing an explicit `jwks-rsa` alias; verify in Gradle during planning.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH for locked choices; MEDIUM for Android secure storage because current docs conflict with the original assumption. [VERIFIED: docs comparison]
- Architecture: HIGH for tier ownership and route/session shape. [VERIFIED: `.planning/phases/02-authentication-foundation/02-CONTEXT.md`]
- Pitfalls: HIGH for Ktor/AppAuth/AuthentiK pitfalls; MEDIUM for exact Exposed API import until version is pinned. [CITED: https://www.jetbrains.com/help/exposed/transactions.html]
**Research date:** 2026-04-27
**Valid until:** 2026-05-04 for auth library/version details; 2026-05-27 for architecture patterns.

View File

@@ -0,0 +1,302 @@
---
phase: 2
slug: authentication-foundation
status: approved
shadcn_initialized: false
preset: none
created: 2026-04-27
reviewed_at: 2026-04-27
---
# Phase 2 — UI Design Contract
> Visual and interaction contract for the Authentication Foundation phase. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
>
> **Stack note:** This is a Kotlin Multiplatform / Compose Multiplatform app, not shadcn/web. The template's "shadcn" framing has been adapted for Material 3 on CMP. All values below are expressed in `dp` (Compose) and Material 3 `Type` / `ColorScheme` roles.
>
> **Phase boundary reminder:** Phase 2 ships SCAFFOLD UI quality — three composables (`SplashScreen`, `LoginScreen`, `PostLoginPlaceholderScreen`) plus the auth gate in `App()`. The Liquid-Glass visual language and Haze blur land in **Phase 10**. The polished Polish copy + display font live in **Phase 11**. Tokens locked here are the SEED that later phases extend, not retroactively rewrite.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | Compose Multiplatform Material 3 |
| Preset | not applicable |
| Component library | `androidx.compose.material3` (CMP port via `compose-multiplatform` 1.7+) |
| Icon library | none used in Phase 2 (no icons on Splash / Login / PostLoginPlaceholder); `androidx.compose.material.icons` available but deferred to Phase 5+ |
| Font | system default — `FontFamily.Default` (Compose Multiplatform resolves to SF on iOS, Roboto on Android, system default on JVM/Wasm). **Reserved for Phase 11:** display font selection + custom `FontFamily` in Compose Resources. |
**Component sourcing:** Material 3 stdlib only (`Surface`, `Text`, `Button`, `OutlinedButton`, `CircularProgressIndicator`, `Column`, `Spacer`, `Box`). No third-party UI components, no Haze, no custom blur. **Haze blur is explicitly deferred to Phase 10** per CLAUDE.md non-negotiable #10 ("Haze on chrome only, never over fast-scrolling content").
---
## Spacing Scale
Declared values (all multiples of 4, expressed in Compose `dp`):
| Token | Value | Phase 2 Usage |
|-------|-------|---------------|
| xs | 4.dp | Reserved for later phases (icon gaps, fine adjustments) |
| sm | 8.dp | Vertical gap between welcome text and "Wyloguj się" button on PostLogin; vertical gap between "Recipe" wordmark and progress indicator on Splash |
| md | 16.dp | Default vertical gap between button and inline error text on LoginScreen; horizontal screen-edge padding on all three screens |
| lg | 24.dp | Vertical gap between app-name display and primary button on LoginScreen; vertical gap between welcome text block and logout button on PostLoginPlaceholder |
| xl | 32.dp | Reserved for later phases (planner/calendar layouts) |
| 2xl | 48.dp | Vertical breathing room between top-most centered content cluster and its surrounding empty space (visual centering target on LoginScreen and Splash) |
| 3xl | 64.dp | Reserved for later phases (page-level section breaks in catalog / planner) |
**Touch target floor:** All interactive controls (buttons) honor Material 3 minimum touch target of `48.dp` height. Material 3's `Button` defaults satisfy this; do not shrink.
**Safe-area insets:** All three screens wrap their root in `Modifier.safeContentPadding()` (already established by Phase 1's `App.kt` pattern). This keeps content clear of the iOS notch/home indicator and Android system bars without introducing platform-specific code in `commonMain`.
**Exceptions:** none. The full `xs..3xl` scale is declared for forward-compat with Phases 3+; tokens marked "Reserved for later phases" are spec'd here so the planner/executor draws from one canonical scale instead of inventing per-phase increments.
---
## Typography
Material 3 `Typography` roles. Phase 2 uses four roles; the rest of the M3 scale is implicitly available for later phases. Phase 11 may swap `FontFamily` but the **role-to-element mapping below is locked**.
| Role | M3 Token | Size | Weight | Line Height | Phase 2 Element |
|------|----------|------|--------|-------------|-----------------|
| Display | `displaySmall` | 36.sp | Regular (W400) | 44.sp (≈1.22) | "Recipe" wordmark on `SplashScreen` and `LoginScreen` |
| Heading | `headlineSmall` | 24.sp | Regular (W400) | 32.sp (≈1.33) | `Witaj, {displayName}!` welcome text on `PostLoginPlaceholderScreen` |
| Body | `bodyLarge` | 16.sp | Regular (W400) | 24.sp (1.5) | Inline error text below the sign-in button on `LoginScreen` |
| Label | `labelLarge` | 14.sp | Medium (W500) | 20.sp (≈1.43) | Button label text — "Zaloguj się przez Authentik", "Wyloguj się" (Material 3 `Button` slot uses this role by default) |
**Weights declared:** exactly 2 — Regular (W400) for body / heading / display, Medium (W500) for button labels (Material 3 default for `labelLarge`). No Bold, no Light, no SemiBold in Phase 2.
**Sizes declared:** exactly 4 — 14, 16, 24, 36. This satisfies the "34 sizes" cap.
**Line-height policy:**
- Body (16.sp body): 1.5 ratio → 24.sp line height. Matches the brand recommendation; Material 3 `bodyLarge` default is 24.sp.
- Heading (24.sp `headlineSmall`): ~1.33 ratio. Tighter than body per Material 3 baseline; aligns with the "calmer typography" direction in PROJECT.md.
- Display (36.sp `displaySmall`): ~1.22 ratio. Material 3 default.
**Implementation:** use `MaterialTheme.typography.displaySmall` / `.headlineSmall` / `.bodyLarge` / `.labelLarge` directly. Do **not** override `style.copy(fontWeight = ...)` ad-hoc in Phase 2 composables — if a deviation is needed, add it to the `Typography` config in `ui/theme/Typography.kt` so Phase 11 has one place to retune.
---
## Color
Material 3 `ColorScheme` derived from a **single seed color** via `dynamicLightColorScheme` / `dynamicDarkColorScheme` is **not** used (dynamic color is Android 12+ only and would diverge between iOS and Android). Instead Phase 2 ships **explicit baseline schemes** seeded once:
- **Seed color:** `#3B6939` (mid-saturation green, warm-leaning — chosen as a placeholder that reads well in a cooking/meal-planning context without committing to a brand identity).
- **Generation:** `lightColorScheme()` / `darkColorScheme()` Material 3 defaults overridden with the seed-derived `primary` only. All other roles use Material 3 baseline values for their respective scheme.
- **Phase 11 hand-off:** the seed value is open to revision in Phase 11 (final brand-color pass). Tokens listed below are CONTRACT for Phase 2; Phase 11 may rebase the entire palette around a different seed without breaking the role-to-element mapping locked here.
The 60 / 30 / 10 split, mapped to Material 3 roles:
| Role | Light scheme | Dark scheme | Usage |
|------|--------------|-------------|-------|
| Dominant (60%) — `surface` | `#FEF7FF` (M3 default) | `#141218` (M3 default) | Root background of all three Phase 2 screens |
| Secondary (30%) — `surfaceContainer` | `#F3EDF7` (M3 default) | `#211F26` (M3 default) | **Reserved for Phase 5+** (cards, sheets, nav containers). Phase 2 has no card surfaces; this token is declared for forward-compat. |
| Accent (10%) — `primary` | `#3B6939` (seed) | `#A2D597` (seed-derived dark variant) | The single primary CTA on each screen — **only**: "Zaloguj się przez Authentik" button (`LoginScreen`) and **only** that button. Logout uses a different role (see below). |
| Destructive — `error` | `#BA1A1A` (M3 default) | `#FFB4AB` (M3 default) | Inline error text color on `LoginScreen` (`auth_error_*` strings). Reserved for actual error states only — not used for the "Wyloguj się" button. |
**Accent reserved for:** the `LoginScreen` primary CTA button (`Button` composable using `colors = ButtonDefaults.buttonColors()` which resolves to `containerColor = primary`). Nothing else in Phase 2.
**"Wyloguj się" button styling:** uses Material 3 `OutlinedButton` (not `Button`) → `borderColor` = `outline`, `contentColor` = `primary`. This is a deliberate hierarchy choice: logout is a less-frequent, more-deliberate action than login, and reserving the filled-accent variant for the login CTA preserves the "10% accent" ratio. **Not** styled as destructive (red `error`) because logout is not destructive in this app — it ends the session but does not delete user data.
**Dark mode is required.** Per orchestrator note (homelab user's primary environment is dark mode), both `lightColorScheme()` and `darkColorScheme()` MUST be wired. App respects system theme via `isSystemInDarkTheme()` (already standard in Compose). No in-app theme toggle in Phase 2.
**Translucency / blur:** none in Phase 2. All surfaces are opaque. The Liquid-Glass aesthetic begins in Phase 10.
---
## Copywriting Contract
All user-facing strings live in **Compose Resources** (`composeApp/src/commonMain/composeResources/values/strings.xml` per Compose Multiplatform conventions) per CLAUDE.md non-negotiable #9 + CONTEXT D-34. Polish copy below is **scaffold quality**; Phase 11 polishes for plural forms, tone, and proofs the full locale.
| Element | Resource Key | Polish Copy (scaffold) | Screen | Notes |
|---------|--------------|------------------------|--------|-------|
| App wordmark | `auth_app_name` | `Recipe` | Splash, Login | English working title per PROJECT.md; final brand name is a Phase 11 decision. Not localizable in Phase 2. |
| Primary CTA | `auth_sign_in_button` | `Zaloguj się przez Authentik` | LoginScreen | Verb + noun; explicit IdP name to set expectation that the system browser will open. |
| Secondary CTA (logout) | `auth_sign_out_button` | `Wyloguj się` | PostLoginPlaceholderScreen | Single Polish reflexive verb; matches user's expected idiom. |
| Welcome / authenticated state | `auth_welcome_format` | `Witaj, %1$s!` | PostLoginPlaceholderScreen | `%1$s` substituted with `User.displayName` from the JIT-provisioned server response. Use Compose Resources `stringResource(Res.string.auth_welcome_format, user.displayName)`. |
| Error: user cancelled | `auth_error_cancelled` | `Logowanie anulowane. Spróbuj ponownie.` | LoginScreen (inline below button) | Triggered when AppAuth surfaces `OIDAuthError.userCancelled` (iOS) / `AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW` (Android). |
| Error: network unreachable | `auth_error_network` | `Nie można połączyć z Authentik. Sprawdź połączenie.` | LoginScreen (inline below button) | Triggered on `IOException` / network errors during authorization OR token exchange. |
| Error: token exchange / validation failure | `auth_error_unknown` | `Coś poszło nie tak. Spróbuj ponownie.` | LoginScreen (inline below button) | Catch-all for token-exchange failures, JWT validation errors, JIT-provisioning 5xx. |
**Empty states:** Phase 2 has no empty-state surfaces. The "no user yet" condition routes to `LoginScreen`, which is itself the empty state. No "you're not signed in yet" placeholder text is needed.
**Destructive confirmation:** none in Phase 2. Logout is silent (CONTEXT D-19): "Wyloguj się" tap immediately initiates RP-initiated end-session without a confirmation modal. **Rationale:** the user can re-authenticate trivially; a confirmation modal here would be cargo-culted from destructive-delete patterns where re-creation is impossible. The post-login screen is a placeholder anyway and gets replaced by household onboarding in Phase 3.
**Refresh-failure UX:** silent transition (CONTEXT D-18). When `AuthSession` detects an `invalid_grant` from a background token refresh, it emits `AuthState.Unauthenticated` and the auth gate routes to `LoginScreen`. No toast, no modal, no error message on the LoginScreen itself (the user landed there silently — there is no "previous attempt" to error about). Logged at `Logger.withTag("auth").w(...)` for diagnostics.
**Inline-error display rules (LoginScreen):**
- Error text is rendered **below** the primary button with `md` (16.dp) vertical gap.
- Button **stays enabled** during the error state — the user can retry by tapping again.
- Tapping the button again **clears** the previous error message before initiating a new login flow (so the user does not see stale error text during the next attempt).
- Error text uses `bodyLarge` typography role, `error` color (see Color section).
- Errors are NOT surfaced as Snackbars in Phase 2. Inline-below-button is the contract; Snackbars require a `Scaffold` host that Phase 2 does not need.
**Loading / pending UX (LoginScreen):**
- While AppAuth's authorization request is in flight (system browser is open), the LoginScreen does NOT need a separate loading state — the system browser is full-screen and obscures the app.
- After the system browser dismisses but before token exchange + JIT-provisioning completes, the button shows a `CircularProgressIndicator` (16.dp) inside its content slot, replacing the label, with the button **disabled**. Total expected duration: <500ms in practice.
- Implementation hint: a `Boolean` `isLoading` flag in `LoginScreenState` controls this.
**Splash UX:**
- Visible during `AuthState.Loading` (deserializing persisted `AuthState` blob, possibly running a refresh).
- Centered "Recipe" wordmark using `displaySmall`.
- `sm` (8.dp) below: a `CircularProgressIndicator` at default size (40.dp), `color = primary`.
- No "Loading..." text. No marketing copy. No tagline.
- Background = `surface` (matches Login + PostLogin to avoid a color flash when the auth gate transitions).
---
## Auth Gate Routing Contract
The `App()` composable observes `AuthSession.state: StateFlow<AuthState>` and renders exactly one of:
| `AuthState` value | Rendered composable |
|-------------------|---------------------|
| `AuthState.Loading` | `SplashScreen()` |
| `AuthState.Unauthenticated` | `LoginScreen(viewModel = koinViewModel())` |
| `AuthState.Authenticated(user, householdId)` | `PostLoginPlaceholderScreen(user, viewModel = koinViewModel())` (Phase 2). Phase 3 replaces with `HouseholdGate`. |
**Transition behavior:** state changes drive recomposition; no manual navigation calls. Material 3 default cross-fade (the implicit `Crossfade` recommended pattern, NOT explicit — keep Phase 2 minimal) is acceptable but not required. **Required:** no white flash between transitions — both screens use the same `surface` background.
Implementation note for executor: replace the existing `App.kt` body (currently the JetBrains template's button-and-greeting demo) with a `when` over `authSession.state.collectAsState().value`. Keep the existing `MaterialTheme { ... }` wrapper.
---
## Component Inventory (Phase 2)
Composables the planner / executor must produce in `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/`:
| Composable | File | Responsibility |
|------------|------|----------------|
| `SplashScreen()` | `SplashScreen.kt` | Stateless. Renders wordmark + progress indicator. No ViewModel — the auth gate above it owns state. |
| `LoginScreen(viewModel: LoginViewModel)` | `LoginScreen.kt` | Stateless wrt auth tokens (those live in `AuthSession`). Owns local UI state for `isLoading` + `errorKind`. Triggers `viewModel.onSignInClick()` which delegates to `AuthSession.login()`. |
| `LoginViewModel` | `LoginViewModel.kt` | Wraps `AuthSession`. Maps `AuthSession.LoginResult``LoginScreenState(isLoading, errorKey: StringResource?)`. Method-per-action: `onSignInClick()`. |
| `LoginScreenState` | (data class in `LoginViewModel.kt`) | `(val isLoading: Boolean, val errorKey: StringResource?)`. Immutable. |
| `PostLoginPlaceholderScreen(user: User, viewModel: PostLoginViewModel)` | `PostLoginPlaceholderScreen.kt` | Renders welcome text + logout button. Triggers `viewModel.onSignOutClick()`. |
| `PostLoginViewModel` | `PostLoginViewModel.kt` | Wraps `AuthSession.logout()`. Trivial in Phase 2 — exists so Phase 3's `HouseholdGate` follows the same VM pattern. |
| `RecipeTheme(content)` | `ui/theme/RecipeTheme.kt` | Top-level theme wrapper applying `lightColorScheme()` / `darkColorScheme()` based on `isSystemInDarkTheme()`. Wraps `MaterialTheme(colorScheme, typography, shapes)`. **Phase 2 ships this seed;** later phases extend with custom typography + shape tokens here. |
**No `Scaffold` in Phase 2.** Each of the three auth screens uses `Surface(modifier = Modifier.fillMaxSize().safeContentPadding())` as the root. `Scaffold` (with its `topBar` / `bottomBar` slots and Snackbar host) lands in Phase 5 (`RecipeListScreen`) or Phase 10 (`MainScaffold` chrome).
---
## Layout Contract
All three screens use a **vertically-centered single-column layout**:
```
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.safeContentPadding()
.padding(horizontal = 16.dp) // md token
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize(),
)
```
**Per-screen content order (top → bottom):**
| Screen | Content |
|--------|---------|
| `SplashScreen` | Wordmark `displaySmall``Spacer(8.dp)``CircularProgressIndicator(color = primary)` |
| `LoginScreen` | Wordmark `displaySmall``Spacer(24.dp)``Button(onClick = onSignInClick) { Text(R.string.auth_sign_in_button) }` (or `CircularProgressIndicator(16.dp)` when `isLoading`) → `Spacer(16.dp)``Text(error, style = bodyLarge, color = error)` if `errorKey != null` |
| `PostLoginPlaceholderScreen` | `Text(stringResource(Res.string.auth_welcome_format, user.displayName), style = headlineSmall)``Spacer(24.dp)``OutlinedButton(onClick = onSignOutClick) { Text(R.string.auth_sign_out_button) }` |
**Width constraint:** content column natural-fits its children. No `widthIn(max = 480.dp)` tablet-narrowing in Phase 2 — the app targets phone-sized iOS first; tablet polish is post-v1.
---
## Component Sourcing & Safety
| Source | Components Used (Phase 2) | Safety Gate |
|--------|---------------------------|-------------|
| Material 3 stdlib (`androidx.compose.material3`) | `Surface`, `MaterialTheme`, `Text`, `Button`, `OutlinedButton`, `CircularProgressIndicator` | not required (first-party Compose Multiplatform stdlib, applied by `recipe.compose.multiplatform` convention plugin per Phase 1 D-07) |
| Compose Foundation (`androidx.compose.foundation`) | `Column`, `Spacer`, `Box`, `Modifier.background`, `Modifier.safeContentPadding`, `Modifier.fillMaxSize`, `Modifier.padding` | not required (first-party) |
| Compose Resources (`org.jetbrains.compose.components:components-resources`) | `stringResource`, generated `Res.string.*` accessors | not required (first-party Compose Multiplatform; Phase 1 generated `Res` accessors already wired) |
| Third-party UI registry | none in Phase 2 | not applicable |
**No Haze, no third-party UI components in Phase 2.** Haze is gated to Phase 10 per CLAUDE.md non-negotiable #10. Adding third-party UI components to the auth scaffold is explicitly out-of-scope.
---
## Accessibility
| Requirement | Implementation |
|-------------|----------------|
| Touch target ≥48dp | Material 3 `Button` / `OutlinedButton` defaults satisfy this; do not shrink |
| Color contrast (WCAG AA) | Material 3 baseline `lightColorScheme()` / `darkColorScheme()` ship WCAG AA-compliant role pairings (e.g., `onPrimary` on `primary`); seed override only changes `primary` so the contrast pairing holds |
| Dynamic type / font scaling | Material 3 `Typography` roles use `sp` (already scale-respecting); no override forcing fixed sizes |
| Screen reader semantics | `Button` carries its label as accessibility text by default; `Text` for the welcome line is announced by VoiceOver / TalkBack as plain content. No custom `Modifier.semantics` overrides required in Phase 2 |
| RTL | not applicable in Phase 2 (Polish is LTR) |
**Phase 11 will revisit:** `contentDescription` on any decorative imagery, semantic grouping of multi-element clusters, full VoiceOver pass on iOS device.
---
## Out-of-Scope (Reserved for Later Phases)
The following intentionally have NO contract in Phase 2:
| Concern | Owning Phase |
|---------|--------------|
| Tab bar / bottom navigation | Phase 10 (`UI Chrome & Haze`) |
| Top app bar / nav bar with Haze blur | Phase 10 |
| Glass / translucent surface tokens | Phase 10 |
| Display font selection + custom `FontFamily` | Phase 11 |
| Polished Polish copy with plural forms (1 / 2 / 5 / 22) | Phase 11 |
| Brand color final pass (re-seeding `primary`) | Phase 11 |
| In-app theme toggle (override system dark/light) | not in v1 (out of scope per PROJECT.md) |
| Animated transitions between auth states | Phase 10 |
| Logo / wordmark image asset | not in v1 — text wordmark only until Phase 11 brand pass |
---
## Checker Sign-Off
- [ ] Dimension 1 Copywriting: PASS — 6 string keys declared with Polish scaffold copy + Compose Resources delivery contract; inline-error UX rules locked; logout silent UX justified
- [ ] Dimension 2 Visuals: PASS — 3 composables specified with file paths, layout column structure, and no-Scaffold-in-Phase-2 boundary
- [ ] Dimension 3 Color: PASS — Material 3 `lightColorScheme()` / `darkColorScheme()` seeded with `#3B6939`; 60/30/10 mapped to `surface` / `surfaceContainer` / `primary`; accent reserved for the single LoginScreen CTA; dark mode required
- [ ] Dimension 4 Typography: PASS — exactly 4 sizes (14/16/24/36), exactly 2 weights (W400/W500), Material 3 role-to-element mapping locked
- [ ] Dimension 5 Spacing: PASS — full xs..3xl scale declared, all multiples of 4, Phase 2 uses sm/md/lg/2xl, others reserved
- [ ] Dimension 6 Component Sourcing: PASS — Material 3 stdlib only, no third-party UI, no Haze in Phase 2 (gated to Phase 10), no registry safety gate needed
**Approval:** pending
---
## UI-SPEC COMPLETE
**Phase:** 2 — Authentication Foundation
**Design System:** Compose Multiplatform Material 3 (no shadcn — KMP project)
### Contract Summary
- **Spacing:** 8-point scale `xs..3xl` (4 / 8 / 16 / 24 / 32 / 48 / 64 dp); Phase 2 actively uses sm / md / lg / 2xl
- **Typography:** 4 sizes (14, 16, 24, 36 sp), 2 weights (W400, W500); Material 3 roles `displaySmall` / `headlineSmall` / `bodyLarge` / `labelLarge`
- **Color:** Material 3 `light` + `dark` schemes seeded with `#3B6939`; 60% `surface` / 30% `surfaceContainer` / 10% `primary`; accent reserved for single LoginScreen CTA; logout uses `OutlinedButton` (not destructive `error`)
- **Copywriting:** 6 Compose Resources keys + Polish scaffold copy locked (`auth_app_name`, `auth_sign_in_button`, `auth_sign_out_button`, `auth_welcome_format`, `auth_error_cancelled`, `auth_error_network`, `auth_error_unknown`); inline-error UX + silent-logout UX defined
- **Component Sourcing:** Material 3 stdlib only — no Haze, no third-party UI registries (Phase 2 has no registry-safety gate to clear)
### File Created
`.planning/phases/02-authentication-foundation/02-UI-SPEC.md`
### Pre-Populated From
| Source | Decisions Used |
|--------|----------------|
| `02-CONTEXT.md` (D-30..D-34) | 5 (auth gate routing, login minimal, login error states, post-login placeholder, Compose Resources) |
| `02-CONTEXT.md` (Claude's Discretion) | 1 resolved here (splash visual = wordmark + circular progress indicator) |
| `PROJECT.md` (locked stack) | 4 (Material 3, system font, Polish-only v1, Liquid-Glass deferred to polish phase) |
| `CLAUDE.md` (non-negotiables) | 2 (#9 strings externalized day 1, #10 Haze on chrome only — gates Phase 2 to no-blur) |
| `ROADMAP.md` (phase boundaries) | 2 (Phase 10 owns UI chrome / Haze, Phase 11 owns localization + final polish) |
| `REQUIREMENTS.md` (AUTH-01..AUTH-06) | 1 (AUTH-05 logout returns to login screen) |
| `ARCHITECTURE.md` (component responsibilities) | 1 (`AuthSession` Koin singleton owning `StateFlow<AuthState>`) |
| `App.kt` (Phase 1 scaffold) | 1 (existing `MaterialTheme { ... }` + `safeContentPadding()` pattern preserved) |
### Awaiting / Notes for Downstream
- **Planner (`gsd-planner`):** the Component Inventory + Layout Contract sections give you concrete file paths and composable shapes; tokens in Spacing / Typography / Color sections are referenced via Material 3 theme accessors (`MaterialTheme.colorScheme.primary`, `MaterialTheme.typography.displaySmall`, etc.). The seed color `#3B6939` is the only manual override needed in `RecipeTheme.kt`.
- **Executor (`gsd-executor`):** replace `App.kt` body with the auth-gate `when`-block; do NOT keep the JetBrains template's button-and-greeting code. Wire `Res.string.*` keys via Compose Resources (`composeApp/src/commonMain/composeResources/values/strings.xml`).
- **Phase 10 / 11 hand-off seam:** every "Reserved for Phase 10/11" annotation in this doc is an explicit hand-off point; do not retroactively rewrite Phase 2's seed tokens during those phases unless the tradeoff is documented.
### Ready for Verification
UI-SPEC complete. Checker can now validate against the 6 design quality dimensions.

View File

@@ -0,0 +1,94 @@
---
phase: 02
slug: authentication-foundation
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-04-27
---
# Phase 02 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | kotlin.test + JUnit for server; KMP common tests for auth state/store seams |
| **Config file** | Existing Gradle/KMP test setup; no standalone test config |
| **Quick run command** | `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest` |
| **Full suite command** | `./gradlew check` |
| **Estimated runtime** | ~120 seconds |
---
## Sampling Rate
- **After every task commit:** Run `./gradlew :server:test :composeApp:jvmTest :shared:jvmTest`
- **After every plan wave:** Run `./gradlew check`
- **Before `$gsd-verify-work`:** Full suite must be green and manual iOS Authentik login/logout UAT must be recorded
- **Max feedback latency:** 120 seconds for quick checks
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 02-01-01 | 01 | 1 | AUTH-01 | T-02-01 | OIDC config pins issuer, client ID, redirect URI, scopes, PKCE-compatible public-client flow | build/unit | `./gradlew :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileDebugKotlinAndroid` | ❌ W0 | ⬜ pending |
| 02-01-02 | 01 | 1 | AUTH-02 | T-02-02 | AuthState JSON store reads/writes/clears without using no-arg insecure Settings for secrets | common unit + grep | `./gradlew :composeApp:jvmTest` | ❌ W0 | ⬜ pending |
| 02-02-01 | 02 | 1 | AUTH-03 | T-02-03 | `/api/v1/me` rejects missing, expired, wrong-audience, and wrong-issuer tokens | server integration | `./gradlew :server:test --tests "*Auth*"` | ❌ W0 | ⬜ pending |
| 02-02-02 | 02 | 1 | AUTH-06 | T-02-04 | First authenticated `/api/v1/me` creates or updates a `users` row keyed by OIDC `sub` | server integration | `./gradlew :server:test --tests "*Me*"` | ❌ W0 | ⬜ pending |
| 02-03-01 | 03 | 1 | AUTH-04 | T-02-05 | Restored AuthState refreshes before `/api/v1/me` and transitions to authenticated without UI prompt | common/platform-stub unit | `./gradlew :composeApp:jvmTest` | ❌ W0 | ⬜ pending |
| 02-03-02 | 03 | 1 | AUTH-05 | T-02-06 | Logout calls end-session when possible and clears local AuthState even if network logout fails | unit + manual iOS UAT | `./gradlew :composeApp:jvmTest` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt` — covers valid, missing, expired, wrong-audience, and wrong-issuer JWT cases for AUTH-03
- [ ] `server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` — covers JIT provisioning and `/api/v1/me` response shape for AUTH-06
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` — covers login, restored session refresh, invalid-grant transition, and logout state transitions for AUTH-01, AUTH-04, AUTH-05
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreTest.kt` — covers read/write/clear store contract and guards against insecure no-arg `Settings()` use for AUTH-02
- [ ] `docs/authentik-setup.md` — includes manual iOS UAT checklist for fresh login, reopen-with-refresh, logout, and `/api/v1/me`
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Fresh iOS install opens Authentik, completes hosted login, and returns through `recipe://callback` | AUTH-01 | Requires real Authentik provider, iOS browser handoff, and custom URL callback | Install on iOS simulator/device, tap `Zaloguj się przez Authentik`, authenticate, verify app shows `Witaj, {displayName}!` |
| Reopen after access token expiry remains signed in via refresh token | AUTH-04 | Depends on Authentik-issued refresh token and persisted OS secure storage | Sign in, close app, wait or force short token lifetime in Authentik, reopen, verify app returns to authenticated screen without credential entry |
| `Wyloguj się` clears local tokens and invokes Authentik end-session | AUTH-05 | Requires browser/end-session behavior that unit tests can only stub | Tap `Wyloguj się`, verify login screen appears, then relaunch and confirm no silent local session restore |
---
## Security Threat References
| Threat Ref | Threat | Required Mitigation |
|------------|--------|---------------------|
| T-02-01 | Authorization-code interception through custom URL scheme | Public client, PKCE S256, AppAuth state/nonce handling, redirect URI byte-match |
| T-02-02 | Refresh token persisted in plaintext | Explicit secure platform store; iOS Keychain and Android secure storage; no no-arg `Settings()` for auth secrets |
| T-02-03 | Wrong-audience or wrong-issuer token accepted by server | `withIssuer`, `withAudience`, 30-second leeway only, non-empty `sub`, negative JWT tests |
| T-02-04 | Duplicate or stale user rows on concurrent first login | Atomic upsert by unique `sub`; update email/display name on conflict |
| T-02-05 | Token expiry breaks reopened sessions | AppAuth `performActionWithFreshTokens` before authenticated calls plus Ktor bearer 401 refresh fallback |
| T-02-06 | Logout leaves recoverable local refresh token | Always clear persisted AuthState after logout attempt, even if end-session fails |
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies represented in Phase 2 plans
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 missing references are mapped into Phase 2 plan tasks
- [x] No watch-mode flags
- [x] Feedback latency target < 120s for quick checks is documented
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** plan-ready 2026-04-27; execution must keep `nyquist_compliant: false` and `wave_0_complete: false` until Wave 0 tests/manual-UAT artifacts actually exist.

View File

@@ -0,0 +1,99 @@
# Decision: Drop CocoaPods, switch to embedAndSign + SwiftPM bridge
**Date:** 2026-04-28
**Status:** Decided, not yet executed
**Trigger:** Xcode build fails with *"Incompatible 'embedAndSign' Task with CocoaPods Dependencies."* The Xcode run script calls `:composeApp:embedAndSignAppleFrameworkForXcode` while the Kotlin CocoaPods plugin is also active — these two iOS framework integration modes are mutually exclusive.
## Decision
Remove the Kotlin CocoaPods plugin. Deliver the shared framework via `embedAndSign` (current Xcode run script stays). Deliver AppAuth-iOS via Swift Package Manager in `iosApp.xcodeproj`. Move all AppAuth calls out of `iosMain` Kotlin and behind a Swift bridge injected via Koin.
## Why
- One integration mode, fewer moving parts (no Podfile, Pods/, .xcworkspace, no Ruby/CocoaPods gem prerequisite).
- Aligns with where Apple tooling is going (SwiftPM is the strategic direction; CocoaPods is in maintenance).
- AppAuth surface in `iosMain` is small and contained — migration is local.
- Eliminates the entire class of "cocoapods vs embedAndSign" Xcode build errors.
## Cost (what we accept)
- `OidcClient.ios.kt` (~231 lines) is rewritten to call a Swift bridge instead of `cocoapods.AppAuth.*` cinterop bindings.
- `iosApp/` gains a small Swift class implementing the bridge using AppAuth-iOS APIs directly.
- D-01 (PROJECT.md) remains AppAuth-iOS — only the *delivery channel* changes (CocoaPods → SwiftPM).
## Surface area in this repo (scanned)
AppAuth-iOS is used in exactly one place:
- `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` (231 lines, imports `cocoapods.AppAuth.*``OIDAuthState`, `OIDAuthorizationRequest`, `OIDAuthorizationService`, `OIDEndSessionRequest`, `OIDExternalUserAgentIOS`, `OIDResponseTypeCode`, error codes).
`SecureAuthStateStore.ios.kt` does NOT depend on AppAuth — it serializes `OIDAuthState` via `NSKeyedArchiver`. After migration, the serialized blob crosses the bridge as `NSData`/`ByteArray` and the Swift side does the archiving. Or we change the on-disk format to JSON of our own AuthState (cleaner; recommended).
## Work plan (execute in a fresh session)
### 1 — Gradle / build config
- `composeApp/build.gradle.kts`:
- Remove `id("org.jetbrains.kotlin.native.cocoapods")` from the `plugins { }` block.
- Remove the entire `cocoapods { ... }` block inside `kotlin { }`.
- Keep `group = "dev.ulfrx.recipe"` and `version = "1.0.0"` (the comment explaining "required by cocoapods plugin" can be deleted; group is also referenced by Compose Resources package naming — do NOT change `group`).
- The framework declaration is already provided by the `recipe.kotlin.multiplatform` convention plugin via `iosTarget.binaries.framework`. Verify it sets `baseName = "ComposeApp"` and `isStatic = true`. If not, add the `binaries.framework { baseName = "ComposeApp"; isStatic = true }` block to the iOS targets in the convention plugin (or inline in composeApp).
- `gradle/libs.versions.toml`: leave `appauth-ios` version entry — repurpose it as the documented SwiftPM pin in `docs/authentik-setup.md`. Or delete it and put the version only in the iOS project's Package.resolved.
### 2 — Delete CocoaPods artifacts
- Delete: `iosApp/Podfile`, `iosApp/Podfile.lock`, `iosApp/Pods/`, `iosApp/iosApp.xcworkspace/`.
- From now on open `iosApp/iosApp.xcodeproj` directly (not `.xcworkspace`).
- The Xcode run script stays — it already invokes `./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`.
### 3 — Add AppAuth-iOS via SwiftPM in Xcode
- Open `iosApp.xcodeproj` → File → Add Package Dependencies → `https://github.com/openid/AppAuth-iOS` → choose "Up to Next Major" from the same major version currently in `libs.versions.toml`.
- Add `AppAuth` product to the `iosApp` target.
### 4 — Swift bridge (in `iosApp/iosApp/`)
- Define a Kotlin interface in `composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/IosAuthBridge.kt`:
```kotlin
interface IosAuthBridge {
suspend fun authorize(presentingVc: UIViewController): AuthBridgeResult
suspend fun endSession(presentingVc: UIViewController, idToken: String): AuthBridgeResult
// refresh, plus serialize/deserialize hooks if you keep OIDAuthState as the persisted blob
}
sealed class AuthBridgeResult { data class Success(...) : AuthBridgeResult(); data class Error(val kind: ErrorKind, val message: String?) : AuthBridgeResult(); object Cancelled : AuthBridgeResult() }
```
Mark with `@OptIn(ExperimentalObjCName::class)` and `@ObjCName` so Swift sees stable names.
- Implement in Swift: `iosApp/iosApp/Auth/AuthBridge.swift` — uses `OIDAuthState`, `OIDAuthorizationService`, etc. Maps AppAuth callbacks → suspending Kotlin via `kotlinx.coroutines` continuation helpers (or callback-style if simpler — pick one and stay consistent).
- Decide AuthState persistence format:
- **Option A (recommended):** Define a Kotlin `AuthTokens` data class (access token, refresh token, id token, expiresAt, scopes). Bridge returns this. `SecureAuthStateStore.ios.kt` persists it as JSON via kotlinx.serialization. Removes the last AppAuth dependency from Kotlin and lets you delete `NSKeyedArchiver`/`NSKeyedUnarchiver` plumbing.
- **Option B:** Keep persisting opaque `NSData` blob produced by Swift via `NSKeyedArchiver(rootObject: OIDAuthState)`. Less rewrite of `SecureAuthStateStore`, but Kotlin is now blind to token contents (can't compute expiry locally).
- Wire in Koin from `iosApp` entry point (`MainViewController.kt` or wherever Koin's iOS module starts): `single<IosAuthBridge> { IosAuthBridgeImpl() }` where `IosAuthBridgeImpl` is an `@ObjCName`-annotated Kotlin shim that holds a reference to a Swift-side instance handed over from `iosApp` Swift code at startup.
### 5 — Rewrite `OidcClient.ios.kt`
- Drop all `cocoapods.AppAuth.*` imports.
- Inject `IosAuthBridge` via constructor (Koin).
- Each `OidcClient` method becomes a thin call into the bridge + result mapping to the existing common `OidcClient` contract (Cancelled / NetworkError / Failed / Success).
- Error code mapping (`OIDErrorCodeUserCanceledAuthorizationFlow`, `OIDErrorCodeProgramCanceledAuthorizationFlow`, `OIDErrorCodeNetworkError`) now lives in Swift, surfaced as `AuthBridgeResult.ErrorKind` enum.
### 6 — Verification
- `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` — Kotlin compiles without cocoapods imports.
- Open Xcode, build & run on simulator — no "incompatible task" error.
- Re-run the Phase 02-07 manual UAT (login → welcome → logout → token refresh).
- `./gradlew check` — all existing tests still green; `LoginViewModelTest` / `AuthSessionTest` are unaffected (they test common code, not iOS actuals).
### 7 — Docs / planning updates
- `.planning/PROJECT.md` § Key Decisions: amend D-01 — "AppAuth-iOS via SwiftPM, called through a Swift bridge from `iosMain`. CocoaPods plugin removed 2026-04-28."
- `.planning/research/PITFALLS.md`: replace the cocoapods-specific pitfall (if any) with a SwiftPM-bridge pitfall ("Swift bridge instances must be handed in from `iosApp` at startup; do not try to instantiate AppAuth from pure Kotlin").
- `docs/authentik-setup.md` (or create it): document SwiftPM step for new contributors, AppAuth-iOS version pin, and how to open the project (`.xcodeproj` directly, not `.xcworkspace`).
- `CLAUDE.md` "Tech stack" line: change "Mobile OIDC: AppAuth on Android; ASWebAuthenticationSession wrapper on iOS (KMP interface)" — your current code uses AppAuth-iOS, not ASWebAuthenticationSession; keep AppAuth-iOS but note the SwiftPM + Swift-bridge delivery.
## Out of scope for this change
- Phase 02-07 manual UAT — must be re-run after the migration, but on the same auth flow.
- Pre-existing failures already logged in `.planning/phases/02-authentication-foundation/deferred-items.md` (Android Robolectric test, iOS ktlint warning).
## Rollback
If the SwiftPM bridge proves harder than expected:
- `git revert` the migration commit(s).
- Restore `Podfile`, run `pod install`, reopen `.xcworkspace`.
- The original cocoapods setup is recoverable from git history.
## Resume signal
Start a fresh Claude Code session in `/Users/rwilk/dev/repo/recipe`. Open this file as the briefing. Plan 02-07 stays at the human-verify checkpoint until the migration lands and UAT passes.

View File

@@ -0,0 +1,24 @@
# Deferred Items — Phase 02 (auth foundation)
## Pre-existing failures discovered during 02-07 `./gradlew check`
### `SecureAuthStateStoreContractTest` (Android JVM unit test) — pre-existing
- **Tests:** `clearRemovesStoredValue`, `writeOverwritesPreviousValueAndReadReturnsLatest`
- **File:** `composeApp/src/androidUnitTest/.../SecureAuthStateStoreContractTest.kt`
- **Failure:** `java.lang.IllegalStateException` at construction (Android Keystore not available in
plain JVM unit tests under Robolectric-less harness).
- **Provenance:** Reproduced on `master` HEAD before any 02-07 change (verified via `git stash`
+ run of `./gradlew :composeApp:testDebugUnitTest`).
- **Not caused by 02-07.** Source plan was 02-04 (Android secure-store actuals). Likely
needs Robolectric or an instrumented (`androidTest`) target. Out of scope for 02-07's
UI gate plan.
- **Action:** Track for a follow-up Android-test infra task; do not block Phase 02 on it.
### Spotless `property-naming` lint in `SecureAuthStateStore.ios.kt:L31` — pre-existing
- Reproduced on `master` HEAD before any 02-07 change.
- Source plan: 02-05 (iOS auth actuals).
- ktlint expects SCREAMING_SNAKE_CASE for an immutable property; the iOS implementation
uses camelCase. Fix is a one-line rename or `suppressLintsFor` annotation.
- Out of scope for 02-07; track for follow-up.

View File

@@ -116,7 +116,9 @@
**Warning signs:** Works on Android, fails on iOS (or vice versa); Authentik logs show `invalid_grant`; no `code_challenge` in auth request; fails on release build only.
**How to avoid:** Authentik provider = "Public" + PKCE S256. Register both `recipe://callback` and `recipe://callback/`. AppAuth (Android) + ASWebAuthenticationSession (iOS) with `usePKCE = true`. Keep the redirect URI in one constant in `shared/commonMain`.
**How to avoid:** Authentik provider = "Public" + PKCE S256. Register both `recipe://callback` and `recipe://callback/`. AppAuth on both platforms — Kotlin actual on Android, Swift `AuthBridge` (over AppAuth-iOS via SwiftPM) called from `iosMain` on iOS with `usePKCE = true`. Keep the redirect URI in one constant in `shared/commonMain`.
**iOS bridge gotcha:** the Swift `AuthBridge` instance must be set on `IosAuthBridgeRegistry.shared.instance` from `iOSApp.init` *before* `KoinIosKt.doInitKoin()` runs — otherwise Koin's `single<IosAuthBridge>` fails on first auth call. Do not try to instantiate AppAuth from pure Kotlin: there is no `cocoapods.AppAuth.*` available since 2026-04-28.
**Phase:** Auth.