From cca3ab7923b2d692db57cb682e32eefe9fd787ce Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Mon, 27 Apr 2026 20:54:21 +0200 Subject: [PATCH] docs(02): create authentication foundation plans --- .planning/ROADMAP.md | 13 +- .../02-01-PLAN.md | 215 +++++++++++++++++ .../02-02-PLAN.md | 218 ++++++++++++++++++ .../02-03-PLAN.md | 200 ++++++++++++++++ .../02-04-PLAN.md | 193 ++++++++++++++++ .../02-05-PLAN.md | 202 ++++++++++++++++ 6 files changed, 1038 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/02-authentication-foundation/02-01-PLAN.md create mode 100644 .planning/phases/02-authentication-foundation/02-02-PLAN.md create mode 100644 .planning/phases/02-authentication-foundation/02-03-PLAN.md create mode 100644 .planning/phases/02-authentication-foundation/02-04-PLAN.md create mode 100644 .planning/phases/02-authentication-foundation/02-05-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 891cb9f..db90a47 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -79,7 +79,7 @@ Plans: ### Phase 3: Households, Membership & Server Data Foundation -**Goal:** Introduce the tenancy model before any feature tables land — `users`, `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household. +**Goal:** Introduce the tenancy model before any feature tables land — `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household. **Depends on:** Phase 2 **Requirements:** HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05 **Success Criteria** (what must be TRUE): @@ -88,7 +88,14 @@ Plans: 3. Once both users are in the same household, any household-scoped API call returns identical data regardless of which member made it. 4. A crafted API request that puts a different `household_id` in the body is ignored — the server always derives `household_id` from the authenticated principal, not the payload. 5. The server starts up and Flyway automatically applies `V1__init.sql` (or equivalent) in the correct order; restarting the server twice in a row is idempotent. -**Plans:** TBD +**Plans:** 5 plans + +Plans: +- [ ] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit +- [ ] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me` +- [ ] 02-03-PLAN.md — AppAuth platform actuals, callback registration, and secure token storage +- [ ] 02-04-PLAN.md — AuthSession state machine, bearer HTTP client, refresh/logout behavior, and Koin wiring +- [ ] 02-05-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT **UI hint:** yes **Research flag:** no @@ -213,7 +220,7 @@ Plans: | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Project Infrastructure & Module Wiring | 7/7 | Complete | 2026-04-24 | -| 2. Authentication Foundation | 0/0 | Not started | - | +| 2. Authentication Foundation | 0/5 | Planned | - | | 3. Households, Membership & Server Data Foundation | 0/0 | Not started | - | | 4. Sync Engine Skeleton | 0/0 | Not started | - | | 5. Recipe Catalog (Read Path) | 0/0 | Not started | - | diff --git a/.planning/phases/02-authentication-foundation/02-01-PLAN.md b/.planning/phases/02-authentication-foundation/02-01-PLAN.md new file mode 100644 index 0000000..214f328 --- /dev/null +++ b/.planning/phases/02-authentication-foundation/02-01-PLAN.md @@ -0,0 +1,215 @@ +--- +phase: 02-authentication-foundation +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - gradle/libs.versions.toml + - shared/build.gradle.kts + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt + - shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt + - composeApp/build.gradle.kts + - server/build.gradle.kts + - docs/authentik-setup.md +autonomous: true +requirements: [AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06] +user_setup: + - service: authentik + why: "OIDC provider for mobile login and server JWT validation" + env_vars: + - name: OIDC_ISSUER + source: "Authentik provider issuer URL" + - name: OIDC_AUDIENCE + source: "Authentik OAuth2 provider client ID" + - name: OIDC_JWKS_URL + source: "Optional JWKS URI from Authentik OpenID configuration" + dashboard_config: + - task: "Create public OAuth2/OIDC provider with PKCE S256, redirect URI recipe://callback, scopes openid profile email offline_access, RS256 signing, single-string audience equal to client_id" + location: "Authentik Admin -> Applications -> Providers" +must_haves: + truths: + - "All Phase 2 plans compile against one shared OIDC config and one /api/v1/me DTO contract" + - "Authentik provider setup documents public client + PKCE S256, scopes openid profile email offline_access, RS256, single-string audience, JWKS, and end-session" + - "Android secure token storage is explicit: auth code must not use no-arg Settings() for tokens" + artifacts: + - path: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt" + provides: "OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI, API_BASE_URL per D-11" + contains: "OIDC_REDIRECT_URI" + - path: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt" + provides: "Serializable /api/v1/me response per D-27" + contains: "@Serializable" + - path: "docs/authentik-setup.md" + provides: "Provider scope mapping and manual UAT checklist per D-10" + contains: "offline_access" + key_links: + - from: "shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt" + to: "docs/authentik-setup.md" + via: "same issuer/client/redirect values" + pattern: "recipe://callback" + - from: "gradle/libs.versions.toml" + to: "composeApp/build.gradle.kts and server/build.gradle.kts" + via: "catalog aliases only; no version literals in module build files" + pattern: "ktor-serverAuthJwt|appauth|androidx-security-crypto" +--- + + +Create the shared contract and dependency foundation for Authentication Foundation. + +Purpose: every downstream plan needs the same DTOs, dependency aliases, and Authentik provider contract before implementation starts. +Output: shared DTO/config files, build dependency wiring, and `docs/authentik-setup.md`. + + + +@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md +@/Users/rwilk/.codex/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/REQUIREMENTS.md +@.planning/phases/02-authentication-foundation/02-CONTEXT.md +@.planning/phases/02-authentication-foundation/02-RESEARCH.md +@.planning/phases/02-authentication-foundation/02-VALIDATION.md +@.planning/phases/02-authentication-foundation/02-PATTERNS.md +@AGENTS.md + + + + + + Task 1: Add shared DTO/config contract and serialization test + + - shared/build.gradle.kts + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/Constants.kt + - shared/src/commonTest/kotlin/dev/ulfrx/recipe/SharedCommonTest.kt + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-11, D-27, D-28) + + shared/build.gradle.kts, shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt, shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt, shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt, shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/dto/MeResponseSerializationTest.kt + + - `Constants.OIDC_REDIRECT_URI` equals exactly `recipe://callback` per D-09. + - `Constants.OIDC_ISSUER` ends with `/`; use placeholder `https://auth.example.invalid/application/o/recipe/` until real homelab value is substituted. + - `Constants.OIDC_CLIENT_ID` equals `recipe-app`. + - `MeResponse` serializes fields `id`, `sub`, `email`, `displayName`, and maps to `User`. + - `shared/commonMain` imports only allowed dependencies: Kotlin stdlib and kotlinx.serialization. + + + Apply `alias(libs.plugins.kotlinSerialization)` to `shared/build.gradle.kts`; add `api(libs.kotlinx.serializationJson)` in `commonMain.dependencies`. + + Add `dev.ulfrx.recipe.shared.Constants` as a public object with `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_REDIRECT_URI`, and `API_BASE_URL`. Add `dev.ulfrx.recipe.shared.dto.User` and `MeResponse` as public `@Serializable` data classes using `String` for the server UUID, with `MeResponse.toUser()`. + + Create `MeResponseSerializationTest` covering round trip, `displayName` wire name, and `ignoreUnknownKeys` compatibility with future `householdId`. + + + ./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata + + + - `grep -q 'OIDC_REDIRECT_URI: String = "recipe://callback"' shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt` + - `grep -q '@Serializable' shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` + - `grep -q 'public fun toUser()' shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt` + - `./tools/verify-shared-pure.sh` exits 0 + - `./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata` exits 0 + + Shared config and DTO contract exists and is tested without violating shared module purity. + + + + Task 2: Add Phase 2 dependency aliases without Ktor patch bump + + - gradle/libs.versions.toml + - composeApp/build.gradle.kts + - server/build.gradle.kts + - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Standard Stack, Open Questions) + + gradle/libs.versions.toml, composeApp/build.gradle.kts, server/build.gradle.kts + + In `gradle/libs.versions.toml`, keep existing `ktor = "3.4.1"` unchanged. Add version keys and aliases for: + `appauth = "0.11.1"`, `androidx-security-crypto = "1.1.0"`, `multiplatformSettings = "1.3.0"`, `exposed = "0.55.0"`, `hikari = "6.2.1"`, plus `kotlinCocoapods` plugin. + + Add libraries: `appauth`, `androidx-security-crypto`, `multiplatform-settings`, `multiplatform-settings-coroutines`, Ktor client core/auth/content-negotiation/logging/okhttp/darwin/cio, `ktor-serializationKotlinxJsonMpp` using `io.ktor:ktor-serialization-kotlinx-json`, Ktor server auth/auth-jwt/call-logging/status-pages, Exposed core/jdbc/java-time, Hikari, `kotlinx-serializationJson`. + + In `composeApp/build.gradle.kts`, apply `alias(libs.plugins.kotlinSerialization)` and `alias(libs.plugins.kotlinCocoapods)`. Add common deps for settings, Ktor client, serialization, and platform deps: Android AppAuth + AndroidX Security Crypto + OkHttp, iOS Darwin, JVM CIO. Configure CocoaPods with `podfile = project.file("../iosApp/Podfile")`, framework `baseName = "ComposeApp"`, `isStatic = true`, and `pod("AppAuth") { version = "2.0.0" }`. + + In `server/build.gradle.kts`, add server auth/JWT/call logging/status pages, Exposed, Hikari, and serialization deps from catalog. Do not add inline versions in build files. + + + ./gradlew :composeApp:dependencies --configuration androidMainCompileClasspath :server:dependencies --configuration runtimeClasspath + + + - `grep -q 'ktor = "3.4.1"' gradle/libs.versions.toml` + - `grep -q 'androidx-security-crypto' gradle/libs.versions.toml` + - `grep -q 'pod("AppAuth")' composeApp/build.gradle.kts` + - `grep -q 'libs.androidx.security.crypto' composeApp/build.gradle.kts` + - `grep -q 'libs.ktor.serverAuthJwt' server/build.gradle.kts` + - `grep -q 'libs.exposed.jdbc' server/build.gradle.kts` + - `./tools/verify-no-version-literals.sh` exits 0 + + Phase 2 dependencies are cataloged and wired while preserving the pinned Ktor version. + + + + Task 3: Document Authentik provider setup and source audit + + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-05 through D-10, D-19, D-21 through D-23) + - .planning/phases/02-authentication-foundation/02-VALIDATION.md + - .planning/ROADMAP.md Phase 2 success criteria + + docs/authentik-setup.md + + Create `docs/authentik-setup.md` with these exact sections: + `## Provider`, `## Scopes`, `## Redirect URI`, `## Server Env Vars`, `## Logout`, `## Manual UAT`, `## Source Audit`. + + Provider section must specify: OAuth2/OIDC public client, authorization code with PKCE S256, no client secret in the app, redirect URI `recipe://callback`, RS256 signing, single-string `aud` equal to `recipe-app`, JWKS URI from the provider's OpenID configuration, and end-session endpoint. + + Scopes section must state the app requests exactly `openid profile email offline_access` and that Authentik must map/allow `offline_access` for refresh tokens. Manual UAT must cover fresh iOS login, reopen/refresh after access-token expiry, logout returning to login, and curl/HTTP verification of `/api/v1/me` returning 200 with valid token and 401 without/wrong-audience token. + + Source Audit must mark all Phase 2 sources covered: GOAL Phase 2, REQ AUTH-01..AUTH-06, RESEARCH constraints, CONTEXT D-01..D-34, UI-SPEC auth screens, VALIDATION Wave 0 tests, PATTERNS file map. Deferred ideas must be listed as excluded: Universal Links/App Links, real Desktop OIDC, Wasm OIDC, Apple Sign-in, Authentik automation. + + + grep -E 'openid profile email offline_access|PKCE S256|single-string|recipe://callback|/api/v1/me|Source Audit' docs/authentik-setup.md + + + - `grep -q 'openid profile email offline_access' docs/authentik-setup.md` + - `grep -q 'offline_access.*refresh' docs/authentik-setup.md` + - `grep -q 'single-string.*aud' docs/authentik-setup.md` + - `grep -q 'AUTH-01.*AUTH-02.*AUTH-03.*AUTH-04.*AUTH-05.*AUTH-06' docs/authentik-setup.md` + - `grep -q 'Universal Links / App Links.*excluded' docs/authentik-setup.md` + + Authentik setup and multi-source audit are reproducible and trace every locked requirement/decision. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| app -> Authentik | Mobile app launches system browser and receives authorization callback through custom URL scheme | +| app -> OS secure storage | Refresh tokens cross from process memory to persistent device storage | +| client -> server | Bearer access tokens cross HTTP boundary | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-01-01 | Spoofing/Elevation | OIDC provider setup | mitigate | Document public client + PKCE S256 + AppAuth state handling + exact `recipe://callback` registration | +| T-02-01-02 | Information Disclosure | token storage dependencies | mitigate | Explicit AndroidX Security Crypto and iOS Keychain store plan; forbid no-arg `Settings()` for auth tokens | +| T-02-01-03 | Elevation | JWT audience config | mitigate | Document single-string `aud` equal to `recipe-app`; server tests in Plan 02 enforce wrong audience 401 | +| T-02-01-04 | Information Disclosure | logs/docs | mitigate | Docs state never log `Authorization` or token bodies; server/client implementation plans include redaction | + + + +Run `./gradlew :shared:jvmTest :shared:compileCommonMainKotlinMetadata`, `./tools/verify-shared-pure.sh`, and `./tools/verify-no-version-literals.sh`. + + + +Downstream server, client, and UI plans have stable imports/config, Authentik setup is documented, Ktor remains at 3.4.1, and Android token security is explicit. + + + +After completion, create `.planning/phases/02-authentication-foundation/02-01-SUMMARY.md`. + diff --git a/.planning/phases/02-authentication-foundation/02-02-PLAN.md b/.planning/phases/02-authentication-foundation/02-02-PLAN.md new file mode 100644 index 0000000..de7bb00 --- /dev/null +++ b/.planning/phases/02-authentication-foundation/02-02-PLAN.md @@ -0,0 +1,218 @@ +--- +phase: 02-authentication-foundation +plan: 02 +type: execute +wave: 2 +depends_on: [02-01] +files_modified: + - server/src/main/resources/application.conf + - server/src/main/resources/db/migration/V1__users.sql + - server/src/main/kotlin/dev/ulfrx/recipe/Database.kt + - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt + - server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt + - server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt + - server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt + - server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt + - server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt + - server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt + - server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt + - server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt +autonomous: true +requirements: [AUTH-03, AUTH-06] +must_haves: + truths: + - "GET /api/v1/me with a valid Authentik-style token returns the JIT-provisioned user record" + - "GET /api/v1/me with missing, expired, wrong-issuer, wrong-audience, or blank-sub token returns 401" + - "First valid request creates a users row keyed by OIDC sub; later request updates email/display_name for the same sub" + - "Authorization headers and bearer token values are not logged" + artifacts: + - path: "server/src/main/resources/db/migration/V1__users.sql" + provides: "users table per D-24" + contains: "CREATE TABLE users" + - path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt" + provides: "Ktor jwt(\"authentik\") verifier with issuer, audience, leeway, JWKS cache/rate limit, non-empty sub" + exports: ["configureAuthentication"] + - path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt" + provides: "Exposed DSL JIT user upsert by sub per D-25/D-26" + exports: ["PrincipalResolver"] + - path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt" + provides: "Protected /api/v1/me route per D-27" + exports: ["meRoute"] + key_links: + - from: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt" + to: "server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt" + via: "install Authentication before route registration" + pattern: "configureAuthentication" + - from: "server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt" + to: "server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt" + via: "authenticated JWT principal resolves to users row" + pattern: "resolve" +--- + + +Implement the Ktor server authentication boundary: Authentik JWT validation, JIT user provisioning, and the protected `/api/v1/me` endpoint. + +Purpose: satisfy AUTH-03 and AUTH-06 while establishing safe server auth patterns for Phase 3 household scoping. +Output: Flyway users migration, auth plugin, resolver, route, and negative JWT/JIT tests. + + + +@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md +@/Users/rwilk/.codex/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/REQUIREMENTS.md +@.planning/ROADMAP.md +@.planning/phases/02-authentication-foundation/02-CONTEXT.md +@.planning/phases/02-authentication-foundation/02-RESEARCH.md +@.planning/phases/02-authentication-foundation/02-VALIDATION.md +@.planning/phases/02-authentication-foundation/02-PATTERNS.md +@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md +@AGENTS.md +@server/src/main/kotlin/dev/ulfrx/recipe/Application.kt +@server/src/main/kotlin/dev/ulfrx/recipe/Database.kt +@server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt + + + + + + Task 1: Create JWT validation tests before auth implementation + + - server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt + - .planning/phases/02-authentication-foundation/02-VALIDATION.md (Wave 0 server tests) + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-21, D-22, D-23) + + server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt, server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt + + - No Authorization header returns 401. + - Expired token returns 401. + - Wrong issuer returns 401. + - Wrong audience returns 401. + - Blank `sub` returns 401. + - Valid RS256 test token returns 200 from a protected test route. + + + Create `JwtTestSupport` to generate an RSA keypair, expose a local JWKS endpoint in `testApplication`, and mint RS256 JWTs with configurable `iss`, `aud`, `sub`, `email`, `name`, and expiry. + + Create `AuthJwtTest` that installs `ContentNegotiation`, `configureAuthentication(AuthConfig(...test issuer/audience/jwks...))`, and a protected test route under `authenticate("authentik")`. Tests must assert the status codes listed in ``. Keep tests independent of Postgres and `Database.migrate`. + + These tests should fail before `AuthPlugin.kt` exists; then continue to Task 2. + + + ./gradlew :server:test --tests "*AuthJwtTest*" + + + - `grep -q 'wrong audience' server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt` + - `grep -q 'blank sub' server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt` + - `grep -q 'RS256' server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt` + - After Task 2, `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0 + + JWT negative coverage exists for AUTH-03 and blocks wrong-audience/issuer regressions. + + + + Task 2: Implement AuthConfig, JWT plugin, logging redaction, and verify Exposed suspend API + + - server/src/main/resources/application.conf + - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt + - server/src/main/kotlin/dev/ulfrx/recipe/Database.kt + - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 4 Exposed API drift) + + server/src/main/resources/application.conf, server/src/main/kotlin/dev/ulfrx/recipe/Application.kt, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt + + Add `oidc { issuer, audience, jwksUrl, leewaySeconds }` to `application.conf` with env overrides `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL` and default leeway `30`. + + Create `AuthConfig.fromApplicationConfig(config)` and `configureAuthentication(authConfig: AuthConfig = AuthConfig.fromApplicationConfig(environment.config))`. `AuthPlugin.kt` must install `jwt("authentik")` using `JwkProviderBuilder(jwksUrl or issuer).cached(10, 15, TimeUnit.MINUTES).rateLimited(10, 1, TimeUnit.MINUTES)`, `.withIssuer(issuer)`, `.withAudience(audience)`, `.acceptLeeway(30)`, and a validate block rejecting null/blank `sub`. + + Install `CallLogging` in `Application.module()` and redact `Authorization`. Never log token bodies or raw Authorization headers. + + Before implementing Task 3 DB code, verify the pinned Exposed suspend transaction API/import against the dependency used by this repo. Run: + `./gradlew :server:dependencyInsight --dependency exposed-jdbc --configuration runtimeClasspath` + Then inspect IDE/Gradle source or compile probe and use whichever import compiles for pinned Exposed: expected for the chosen catalog version is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, use that exact import and note it in `02-02-SUMMARY.md`. Do not use blocking `transaction {}` inside suspend route code. + + + ./gradlew :server:test --tests "*AuthJwtTest*" + + + - `grep -q 'OIDC_ISSUER' server/src/main/resources/application.conf` + - `grep -q 'jwt("authentik")' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` + - `grep -q 'withAudience' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` + - `grep -q 'acceptLeeway(30' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` + - `grep -q 'rateLimited(10, 1, TimeUnit.MINUTES)' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` + - `grep -q 'redactHeader(HttpHeaders.Authorization)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` + - `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0 + + Server rejects invalid JWTs, accepts valid Authentik-style JWTs, and redacts Authorization headers. + + + + Task 3: Add users migration, Exposed resolver, /api/v1/me route, and JIT tests + + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-24 through D-27) + - server/src/main/kotlin/dev/ulfrx/recipe/Database.kt + - server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt + + server/src/main/resources/db/migration/V1__users.sql, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt, server/src/main/kotlin/dev/ulfrx/recipe/Application.kt, server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt + + Create `V1__users.sql` exactly with `users(id UUID PRIMARY KEY DEFAULT gen_random_uuid(), sub TEXT NOT NULL UNIQUE, email TEXT NOT NULL, display_name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())` plus `CREATE INDEX users_sub_idx ON users(sub);`. + + Add Exposed `UsersTable` using DSL only. Add `Database.connect(app)` using Hikari or direct Exposed connection after Flyway migration. + + Implement `PrincipalResolver.resolve(jwtPrincipal)` as a suspend function that extracts non-empty `sub`, `email`, and `name`/`preferred_username` fallback, then performs atomic Postgres upsert by `sub` updating `email`, `display_name`, `updated_at = now()` and returning a `User`/`MeResponse`. Use the verified suspend transaction import from Task 2. Do not select-then-insert. + + Add `meRoute(principalResolver)` under `authenticate("authentik") { get("/api/v1/me") { ... } }`. Wire route from `configureRouting`. + + Create `MeRouteTest` for valid token creates row, second token same `sub` updates email/display name without duplicating, and valid response body contains `id`, `sub`, `email`, `displayName`. + + + ./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*" + + + - `grep -q 'CREATE TABLE users' server/src/main/resources/db/migration/V1__users.sql` + - `grep -q 'sub TEXT NOT NULL UNIQUE' server/src/main/resources/db/migration/V1__users.sql` + - `! grep -R 'org.jetbrains.exposed.dao' server/src/main/kotlin/dev/ulfrx/recipe/auth` + - `! grep -R 'transaction {' server/src/main/kotlin/dev/ulfrx/recipe/auth` + - `grep -q 'ON CONFLICT (sub) DO UPDATE' server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt` + - `grep -q 'get("/api/v1/me")' server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt` + - `./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"` exits 0 + + `/api/v1/me` validates tokens, JIT-provisions users atomically, and returns the shared DTO. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client -> Ktor | Untrusted bearer token arrives in Authorization header | +| Ktor -> Authentik JWKS | Server fetches signing keys from Authentik | +| Ktor -> Postgres | Authenticated claims become persisted user rows | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-02-01 | Elevation | JWT verifier | mitigate | Validate issuer, audience, expiry, RS256 signature, 30s leeway, and non-empty `sub`; negative tests for wrong audience/issuer/blank sub | +| T-02-02-02 | Denial of Service | JWKS provider | mitigate | Configure cache size 10 / 15 min and rate limit 10 per minute per D-22 | +| T-02-02-03 | Information Disclosure | server logs | mitigate | Ktor CallLogging redacts Authorization and code never logs bearer token bodies | +| T-02-02-04 | Tampering | JIT provisioning | mitigate | Atomic upsert on unique `sub`; no client-supplied user ID | +| T-02-02-05 | Repudiation | user updates | accept | Phase 2 records current `updated_at`; full audit log is out of scope for small household v1 | + + + +Run `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` and then `./gradlew :server:test`. + + + +AUTH-03 and AUTH-06 are satisfied: valid tokens return `/api/v1/me`, invalid tokens return 401, and user rows are created/updated by OIDC `sub`. + + + +After completion, create `.planning/phases/02-authentication-foundation/02-02-SUMMARY.md`. + diff --git a/.planning/phases/02-authentication-foundation/02-03-PLAN.md b/.planning/phases/02-authentication-foundation/02-03-PLAN.md new file mode 100644 index 0000000..322d3c3 --- /dev/null +++ b/.planning/phases/02-authentication-foundation/02-03-PLAN.md @@ -0,0 +1,200 @@ +--- +phase: 02-authentication-foundation +plan: 03 +type: execute +wave: 2 +depends_on: [02-01] +files_modified: + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt + - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt + - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt + - composeApp/src/androidMain/AndroidManifest.xml + - composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt + - composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt + - iosApp/iosApp/Info.plist + - iosApp/iosApp/iOSApp.swift + - iosApp/Podfile + - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt + - composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt + - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt +autonomous: true +requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05] +must_haves: + truths: + - "iOS and Android login use AppAuth authorization-code flow with PKCE through system browser and recipe://callback" + - "Requested scopes are exactly openid profile email offline_access" + - "AuthState JSON is stored through explicit iOS Keychain and Android EncryptedSharedPreferences-backed stores" + - "JVM target has DEV_AUTH_TOKEN dev stub; Wasm target throws NotImplementedError(\"Wasm OIDC: v2\")" + - "Logout platform clients support RP-initiated end-session and local store clearing" + artifacts: + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt" + provides: "expect OIDC seam with suspend login/refresh/logout per D-01..D-04" + contains: "expect class OidcClient" + - path: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt" + provides: "Android explicit secure token storage per AUTH-02" + contains: "EncryptedSharedPreferences" + - path: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt" + provides: "iOS Keychain storage with AfterFirstUnlockThisDeviceOnly per D-14" + contains: "kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly" + - path: "iosApp/iosApp/Info.plist" + provides: "recipe URL scheme registration" + contains: "CFBundleURLSchemes" + key_links: + - from: "composeApp/src/androidMain/AndroidManifest.xml" + to: "composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt" + via: "AppAuth redirect receiver for recipe://callback" + pattern: "RedirectUriReceiverActivity|recipe" + - from: "iosApp/iosApp/iOSApp.swift" + to: "composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt" + via: "openURL forwards callback to current AppAuth external user-agent session" + pattern: "onOpenURL|currentAuthorizationFlow" +--- + + +Implement the platform OIDC and secure storage boundary for mobile auth. + +Purpose: satisfy AUTH-01/AUTH-02 platform requirements before `AuthSession` composes them into app state. +Output: expect/actual OIDC client, explicit secure auth state store, URL callback registration, and platform stubs. + + + +@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md +@/Users/rwilk/.codex/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/REQUIREMENTS.md +@.planning/phases/02-authentication-foundation/02-CONTEXT.md +@.planning/phases/02-authentication-foundation/02-RESEARCH.md +@.planning/phases/02-authentication-foundation/02-VALIDATION.md +@.planning/phases/02-authentication-foundation/02-PATTERNS.md +@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md +@AGENTS.md +@composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt +@iosApp/iosApp/iOSApp.swift + + + + + + Task 1: Define OidcClient and secure store common contracts + + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01..D-06, D-13..D-20) + - .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 1) + + composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcResult.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt + + Create `OidcResult` sealed type with `Success(authStateJson: String, accessToken: String, idToken: String?, expiresAtEpochMillis: Long)`, `Cancelled`, `NetworkError`, and `AuthError`. + + Create `expect class OidcClient` with `suspend fun login(): OidcResult`, `suspend fun refresh(authStateJson: String): OidcResult`, and `suspend fun logout(authStateJson: String): Unit`. The contract must state that native actuals use AppAuth and request scopes exactly `openid profile email offline_access`. + + Create `expect class SecureAuthStateStore` with `fun read(): String?`, `fun write(authStateJson: String)`, and `fun clear()`. Add contract tests using a fake in-memory implementation to lock read/write/clear semantics; platform implementations compile in Task 2. + + + ./gradlew :composeApp:jvmTest + + + - `grep -q 'expect class OidcClient' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` + - `grep -q 'openid profile email offline_access' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt` + - `grep -q 'expect class SecureAuthStateStore' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt` + - `./gradlew :composeApp:jvmTest` exits 0 + + Common auth platform seams exist with testable store semantics and exact scope contract. + + + + Task 2: Implement Android and iOS AppAuth actuals with explicit secure storage + + - composeApp/build.gradle.kts + - composeApp/src/androidMain/AndroidManifest.xml + - iosApp/iosApp/Info.plist + - iosApp/iosApp/iOSApp.swift + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-01, D-04, D-09, D-13, D-14, D-19, D-20) + + composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt, composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt, composeApp/src/androidMain/AndroidManifest.xml, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt, composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt, iosApp/iosApp/Info.plist, iosApp/iosApp/iOSApp.swift, iosApp/Podfile + + Android: implement AppAuth-Android using `AuthorizationServiceConfiguration.fetchFromIssuer`, `AuthorizationRequest.Builder`, `ResponseTypeValues.CODE`, `setScopes("openid", "profile", "email", "offline_access")`, AppAuth PKCE defaults, `suspendCancellableCoroutine`, `AuthState.jsonSerializeString()`, `AuthState.jsonDeserialize(...)`, `performActionWithFreshTokens`, and `EndSessionRequest` when metadata exposes end-session. Register `net.openid.appauth.RedirectUriReceiverActivity` for scheme `recipe` host `callback`. + + Android secure storage decision: use AndroidX Security Crypto `EncryptedSharedPreferences` behind `SecureAuthStateStore.android.kt` for AUTH-02 because the requirement explicitly calls out Android EncryptedSharedPreferences. Document in code comment that the dependency is deprecated upstream but isolated behind `SecureAuthStateStore`; do not use no-arg `Settings()` or ordinary `SharedPreferences` for auth tokens. + + iOS: implement AppAuth-iOS via CocoaPods/interop using `OIDAuthorizationService`, `OIDAuthState`, `OIDAuthorizationRequest`, `OIDTokenRequest` refresh/fresh-token helpers, `OIDEndSessionRequest`, and `suspendCancellableCoroutine`. Register `CFBundleURLTypes` for `recipe`. Add SwiftUI `.onOpenURL` or app delegate bridge in `iOSApp.swift` to resume the current AppAuth flow. + + iOS secure storage: implement Keychain read/write/delete with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. Persist the full AppAuth AuthState JSON blob per D-13. + + + ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 + + + - `grep -q 'EncryptedSharedPreferences' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.android.kt` + - `! grep -R 'Settings()' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth` + - `grep -q 'kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.ios.kt` + - `grep -q 'offline_access' composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.android.kt` + - `grep -q 'offline_access' composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.ios.kt` + - `grep -q 'recipe' composeApp/src/androidMain/AndroidManifest.xml` + - `grep -q 'CFBundleURLSchemes' iosApp/iosApp/Info.plist` + - `./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0 + + Mobile targets compile with AppAuth login/refresh/logout and explicit secure AuthState persistence. + + + + Task 3: Add JVM and Wasm target actuals + + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-02, D-03) + - composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/main.kt + - composeApp/src/webMain/kotlin/dev/ulfrx/recipe/main.kt + + composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt, composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt + + JVM actual reads `DEV_AUTH_TOKEN` from environment and returns `OidcResult.Success(authStateJson = "dev:$token", accessToken = token, idToken = null, expiresAtEpochMillis = Long.MAX_VALUE)` when present. If missing, return `OidcResult.AuthError("DEV_AUTH_TOKEN is not set")`; do not hardcode a usable bearer token. + + Wasm actual throws exactly `NotImplementedError("Wasm OIDC: v2")` for login/refresh/logout per D-03. + + + ./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs + + + - `grep -q 'DEV_AUTH_TOKEN' composeApp/src/jvmMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.jvm.kt` + - `grep -q 'NotImplementedError("Wasm OIDC: v2")' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt` + - `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0 + + Secondary targets compile without expanding Phase 2 scope into real Desktop/Wasm OIDC. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| system browser -> app | Authorization code returns through custom URL scheme | +| app process -> OS secure storage | AuthState JSON containing refresh token is persisted | +| app -> Authentik | Refresh and end-session requests exchange tokens with IdP | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-03-01 | Spoofing/Elevation | custom URL callback | mitigate | AppAuth handles state/nonce and PKCE S256; redirect URI byte-matched to `recipe://callback` | +| T-02-03-02 | Information Disclosure | Android token store | mitigate | Use EncryptedSharedPreferences behind `SecureAuthStateStore`; grep forbids no-arg `Settings()` in auth | +| T-02-03-03 | Information Disclosure | iOS token store | mitigate | Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | +| T-02-03-04 | Information Disclosure | AppAuth diagnostics | mitigate | Do not log AuthState JSON, access tokens, refresh tokens, id tokens, or Authorization headers | +| T-02-03-05 | Spoofing | JVM dev stub | accept | Desktop is dev tool only; stub requires explicit `DEV_AUTH_TOKEN` and is not a release surface | + + + +Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileKotlinWasmJs`. + + + +AUTH-01/AUTH-02 platform primitives exist: native AppAuth login/refresh/logout compiles, secure stores are explicit, and secondary target stubs match decisions. + + + +After completion, create `.planning/phases/02-authentication-foundation/02-03-SUMMARY.md`. + diff --git a/.planning/phases/02-authentication-foundation/02-04-PLAN.md b/.planning/phases/02-authentication-foundation/02-04-PLAN.md new file mode 100644 index 0000000..7eabb23 --- /dev/null +++ b/.planning/phases/02-authentication-foundation/02-04-PLAN.md @@ -0,0 +1,193 @@ +--- +phase: 02-authentication-foundation +plan: 04 +type: execute +wave: 3 +depends_on: [02-02, 02-03] +files_modified: + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt + - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt +autonomous: true +requirements: [AUTH-01, AUTH-02, AUTH-04, AUTH-05] +must_haves: + truths: + - "AuthSession starts in Loading and restores persisted AuthState JSON before deciding Authenticated or Unauthenticated" + - "Authenticated state contains User and householdId = null in Phase 2" + - "Authenticated API calls get fresh access tokens proactively and Ktor bearer auth can reactively refresh on 401" + - "Refresh invalid_grant transitions silently to Unauthenticated" + - "logout() attempts RP end-session and clears local AuthState even if end-session fails" + - "AuthSession is a Koin singleton in authModule and wired into appModule" + artifacts: + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt" + provides: "Loading/Unauthenticated/Authenticated(user, householdId?) state model per D-28" + contains: "householdId" + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" + provides: "StateFlow auth owner per D-29" + exports: ["AuthSession"] + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt" + provides: "Ktor client bearer auth with refreshTokens per D-17" + contains: "refreshTokens" + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt" + provides: "GET /api/v1/me client returning MeResponse" + key_links: + - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" + to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt" + via: "login/refresh/logout delegate to platform AppAuth seam" + pattern: "oidcClient" + - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" + to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt" + via: "after login/restore, fetch /api/v1/me to build Authenticated(user, null)" + pattern: "meClient" +--- + + +Build the common client auth runtime: `AuthSession`, authenticated Ktor client, `/api/v1/me` client, and Koin wiring. + +Purpose: compose platform OIDC/storage from Plan 03 with server `/api/v1/me` from Plan 02 into persistent app session behavior. +Output: tested common auth state machine and DI module. + + + +@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md +@/Users/rwilk/.codex/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/REQUIREMENTS.md +@.planning/phases/02-authentication-foundation/02-CONTEXT.md +@.planning/phases/02-authentication-foundation/02-RESEARCH.md +@.planning/phases/02-authentication-foundation/02-VALIDATION.md +@.planning/phases/02-authentication-foundation/02-PATTERNS.md +@.planning/phases/02-authentication-foundation/02-02-SUMMARY.md +@.planning/phases/02-authentication-foundation/02-03-SUMMARY.md +@AGENTS.md +@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt + + + + + + Task 1: Write AuthSession state-machine tests + + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStore.kt + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt + - .planning/phases/02-authentication-foundation/02-VALIDATION.md (AuthSessionTest) + + composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt + + - Empty store initializes `Loading -> Unauthenticated`. + - Successful login writes AuthState JSON, calls `/api/v1/me`, and emits `Authenticated(user, householdId = null)`. + - Existing store refreshes before `/api/v1/me` and emits Authenticated without login. + - Refresh `invalid_grant` or AuthError clears store and emits Unauthenticated without UI error. + - Logout calls `OidcClient.logout(authStateJson)` then clears store and emits Unauthenticated even when logout throws. + - Login cancelled maps to a result the UI can render as cancelled. + + + Create fakes for `OidcClient`, `SecureAuthStateStore`, and `MeClient`. Write tests for the exact behaviors above before production implementation. Keep tests in commonTest and avoid platform AppAuth classes. + + + ./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*" + + + - `grep -q 'invalid_grant\\|AuthError' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` + - `grep -q 'householdId' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt` + - After Task 2, `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0 + + State-machine tests cover AUTH-04/AUTH-05 and validation Wave 0 requirements. + + + + Task 2: Implement AuthState, AuthSession, MeClient, and bearer HTTP client + + - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/Constants.kt + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/User.kt + - shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-16, D-17, D-18, D-28, D-29) + + composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/MeClient.kt + + Create sealed `AuthState` with `Loading`, `Unauthenticated`, `Authenticated(user: User, householdId: HouseholdId? = null)` and `typealias HouseholdId = String`. + + Implement `MeClient.getMe(accessToken: String? = null)` calling `GET ${Constants.API_BASE_URL}/api/v1/me`, decoding `MeResponse`, and mapping to `User`. If `accessToken` is supplied for tests/simple calls, attach `Authorization: Bearer ` without logging it. + + Implement `AuthHttpClient.create(authSession)` using Ktor Client `Auth { bearer { loadTokens { ... }; refreshTokens { ... }; sendWithoutRequest { request.url.host == Url(Constants.API_BASE_URL).host } } }`, ContentNegotiation JSON, and logging that redacts token-bearing headers. + + Implement `AuthSession` with `state: StateFlow`, `initialize()`, `login()`, `logout()`, `getAccessToken()`, `currentBearerTokens()`, and `refreshBearerTokens()`. `initialize()` reads stored AuthState JSON, refreshes with AppAuth, persists updated JSON, calls `/api/v1/me`, and emits `Authenticated(user, null)`. On refresh failure, clear the store and emit Unauthenticated silently. On logout, call `OidcClient.logout(storedJson)` first, then always clear. + + + ./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*" + + + - `grep -q 'StateFlow' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt` + - `grep -q 'refreshTokens' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` + - `grep -q 'sendWithoutRequest' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthHttpClient.kt` + - `! grep -R 'Authorization.*\\$' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth` + - `./gradlew :composeApp:jvmTest --tests "*AuthSessionTest*"` exits 0 + + Common auth runtime passes the state-machine tests and supports transparent refresh. + + + + Task 3: Wire authModule into Koin + + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/Koin.kt + - composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/MainApplication.kt + - .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-29) + + composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt + + Create `authModule = module { ... }` providing singleton `SecureAuthStateStore`, `OidcClient`, `MeClient`, `AuthSession`, and auth-related ViewModels only if their classes already exist. Wire `appModule` to include auth definitions without starting Koin from composables. If target-specific constructors need Android context/activity, use Koin platform APIs already available in Phase 1 Android bootstrap. + + + ./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 + + + - `grep -q 'val authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt` + - `grep -q 'authModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` + - `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0 + + AuthSession and collaborators are available as Koin singletons for the UI gate. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| AuthSession -> server | Access token attached to `/api/v1/me` | +| AuthSession -> secure store | Refresh-capable AuthState JSON persists across app launches | +| AuthSession -> UI | Auth failures influence rendered state and messages | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-04-01 | Information Disclosure | AuthHttpClient logging | mitigate | Redact Authorization and never log token values | +| T-02-04-02 | Information Disclosure | AuthSession logout | mitigate | Always clear stored AuthState after logout attempt, including end-session failure | +| T-02-04-03 | Denial of Service | refresh path | mitigate | Proactive refresh before calls and Ktor bearer reactive refresh on 401 | +| T-02-04-04 | Spoofing | `/api/v1/me` response | mitigate | Authenticated state is built only from server `MeResponse`, not client-decoded token claims | +| T-02-04-05 | Repudiation | silent invalid_grant | accept | Silent return to login is a locked UX decision D-18; auth warnings may log without secrets | + + + +Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`. + + + +AUTH-04 and AUTH-05 work at the session layer: persisted tokens restore, refresh failures clear state, and logout wipes recoverable refresh tokens. + + + +After completion, create `.planning/phases/02-authentication-foundation/02-04-SUMMARY.md`. + diff --git a/.planning/phases/02-authentication-foundation/02-05-PLAN.md b/.planning/phases/02-authentication-foundation/02-05-PLAN.md new file mode 100644 index 0000000..0b8a56b --- /dev/null +++ b/.planning/phases/02-authentication-foundation/02-05-PLAN.md @@ -0,0 +1,202 @@ +--- +phase: 02-authentication-foundation +plan: 05 +type: execute +wave: 4 +depends_on: [02-04] +files_modified: + - composeApp/src/commonMain/composeResources/values/strings.xml + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt + - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt +autonomous: false +requirements: [AUTH-01, AUTH-04, AUTH-05] +must_haves: + truths: + - "Fresh launch in Loading shows SplashScreen with Recipe wordmark and progress indicator" + - "Unauthenticated state shows LoginScreen with Polish Authentik sign-in button" + - "Login errors render inline below the button and retry clears stale error" + - "Authenticated state shows Witaj, {displayName}! and Wyloguj się" + - "Wyloguj się returns to LoginScreen through AuthSession.logout()" + - "All Phase 2 user-facing strings come from Compose Resources" + artifacts: + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt" + provides: "Auth gate rendering Splash/Login/PostLogin by AuthState" + contains: "when" + - path: "composeApp/src/commonMain/composeResources/values/strings.xml" + provides: "auth_app_name/auth_sign_in_button/auth_sign_out_button/auth_welcome_format/auth_error_*" + contains: "auth_sign_in_button" + - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt" + provides: "UI-SPEC login layout and inline error state" + contains: "auth_sign_in_button" + key_links: + - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt" + to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" + via: "collectAsState over AuthSession.state" + pattern: "collectAsState" + - from: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt" + to: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt" + via: "onSignOutClick delegates to logout" + pattern: "logout" +--- + + +Deliver the user-facing Phase 2 auth experience and final validation gate. + +Purpose: make end-to-end auth observable: login button, loading screen, welcome confirmation, logout button, and manual iOS Authentik UAT. +Output: auth screens, auth gate, resource strings, UI ViewModels, and validation checklist execution. + + + +@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md +@/Users/rwilk/.codex/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/REQUIREMENTS.md +@.planning/ROADMAP.md +@.planning/phases/02-authentication-foundation/02-CONTEXT.md +@.planning/phases/02-authentication-foundation/02-UI-SPEC.md +@.planning/phases/02-authentication-foundation/02-VALIDATION.md +@.planning/phases/02-authentication-foundation/02-PATTERNS.md +@.planning/phases/02-authentication-foundation/02-04-SUMMARY.md +@AGENTS.md +@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt + + + + + + Task 1: Add Compose Resources, theme seed, and ViewModel tests + + - .planning/phases/02-authentication-foundation/02-UI-SPEC.md + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt + + composeApp/src/commonMain/composeResources/values/strings.xml, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt, composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt + + - String keys exist with exact Polish scaffold copy from UI-SPEC. + - `RecipeTheme` uses Material 3 light/dark schemes with primary seed `#3B6939` / dark variant `#A2D597`. + - LoginViewModel maps cancelled/network/unknown auth failures to the correct string resource keys. + - Starting a new login clears previous inline error and sets loading. + + + Create `strings.xml` keys: `auth_app_name`, `auth_sign_in_button`, `auth_sign_out_button`, `auth_welcome_format`, `auth_error_cancelled`, `auth_error_network`, `auth_error_unknown` with exact UI-SPEC copy. + + Add `RecipeTheme(content)` with `lightColorScheme(primary = Color(0xFF3B6939))`, `darkColorScheme(primary = Color(0xFFA2D597))`, `isSystemInDarkTheme()`, and Material 3 typography defaults. Do not add Haze, blur, images, icons, Scaffold, or marketing copy. + + Write `LoginViewModelTest` against a fake `AuthSession` result interface before implementing the ViewModel in Task 2. + + + ./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*" + + + - `grep -q 'name="auth_sign_in_button">Zaloguj się przez Authentik' composeApp/src/commonMain/composeResources/values/strings.xml` + - `grep -q '0xFF3B6939' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` + - `grep -q 'auth_error_cancelled' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt` + - After Task 2, `./gradlew :composeApp:jvmTest --tests "*LoginViewModelTest*"` exits 0 + + Resource and theme foundations match UI-SPEC and login error mapping is tested. + + + + Task 2: Implement auth screens, ViewModels, and App auth gate + + - .planning/phases/02-authentication-foundation/02-UI-SPEC.md (Component Inventory, Layout Contract, Auth Gate Routing Contract) + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthSession.kt + - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt + + composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/SplashScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt + + Replace template `App()` body with `RecipeTheme { val authState by authSession.state.collectAsState(); when(authState) { Loading -> SplashScreen(); Unauthenticated -> LoginScreen(koinViewModel()); Authenticated -> PostLoginPlaceholderScreen(user, koinViewModel()) } }`. State changes drive recomposition; no manual navigation or Scaffold. + + Implement `SplashScreen`, `LoginScreen`, and `PostLoginPlaceholderScreen` exactly from UI-SPEC: centered column, `safeContentPadding`, horizontal 16.dp, displaySmall wordmark, Login button with loading indicator, inline bodyLarge error text below button, welcome `headlineSmall`, logout `OutlinedButton`. All strings must use `stringResource(Res.string.*)`. + + Implement `LoginViewModel` with method `onSignInClick()` and immutable `LoginScreenState(isLoading: Boolean, errorKey: StringResource?)`. Implement `PostLoginViewModel.onSignOutClick()` delegating to `AuthSession.logout()`. Register ViewModels in `authModule` using existing Koin Compose ViewModel pattern. + + + ./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 + + + - `! grep -R 'Click me!' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` + - `grep -q 'collectAsState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` + - `grep -q 'SplashScreen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` + - `grep -q 'auth_welcome_format' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt` + - `! grep -R 'Zaloguj\\|Wyloguj\\|Witaj\\|Nie można\\|Coś poszło' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe --include='*.kt'` + - `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64` exits 0 + + Auth gate UI compiles, uses resources, and has no dangling reference to a missing plan. + + + + Task 3: Manual iOS Authentik UAT + + - docs/authentik-setup.md + - .planning/phases/02-authentication-foundation/02-VALIDATION.md (Manual-Only Verifications) + + docs/authentik-setup.md, .planning/phases/02-authentication-foundation/02-05-SUMMARY.md + + Run automated gate first: `./gradlew check`. + + Then perform the manual UAT from `docs/authentik-setup.md` on iOS simulator/device with the real Authentik provider: + 1. Fresh install opens Splash then LoginScreen. + 2. Tap `Zaloguj się przez Authentik`; hosted Authentik login opens and returns through `recipe://callback`. + 3. App shows `Witaj, {displayName}!`. + 4. Restart after access-token expiry or shortened token lifetime; app returns to authenticated screen without credentials. + 5. Tap `Wyloguj się`; app returns to LoginScreen; restart does not silently authenticate. + 6. `GET /api/v1/me` returns 200 with valid token and 401 without token or with wrong-audience token. + + + ./gradlew check + + + - `./gradlew check` exits 0 + - Manual UAT result recorded in `.planning/phases/02-authentication-foundation/02-05-SUMMARY.md` + - If any UAT step fails, record exact step, observed behavior, logs with tokens redacted, and do not mark Phase 2 complete + + Phase 2 end-to-end auth flow: Authentik login, secure token persistence, server /me, and logout UI. + Follow the six UAT steps in the action block using the real Authentik provider configured from docs/authentik-setup.md. + Type "approved" if UAT passes, or describe the failing step and observed behavior. + Automated tests are green and the user confirms fresh login, persisted session refresh, logout, and /api/v1/me behavior. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| UI -> AuthSession | User taps login/logout and triggers token-bearing flows | +| AuthSession -> UI | Auth errors are mapped to user-visible strings | +| Human UAT -> logs | Manual validation may inspect logs while tokens exist | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-02-05-01 | Information Disclosure | UI/log validation | mitigate | UAT summary must redact tokens and Authorization headers | +| T-02-05-02 | Information Disclosure | logout UX | mitigate | Logout button delegates to AuthSession.logout; UAT verifies no silent restore after relaunch | +| T-02-05-03 | Spoofing | login UX | mitigate | Button explicitly opens Authentik; AppAuth handles browser flow and callback | +| T-02-05-04 | Denial of Service | refresh UX | mitigate | Reopen-after-expiry UAT verifies transparent refresh path | +| T-02-05-05 | Tampering | raw strings | mitigate | All auth copy comes from Compose Resources, preventing ad hoc UI string drift | + + + +Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64`, then `./gradlew check`, then complete iOS Authentik UAT. + + + +The app visibly satisfies Phase 2 roadmap criteria: sign in, stay signed in, sign out, and prove server `/api/v1/me` works with valid/invalid tokens. + + + +After completion, create `.planning/phases/02-authentication-foundation/02-05-SUMMARY.md`. +