docs(02): create authentication foundation plans
This commit is contained in:
215
.planning/phases/02-authentication-foundation/02-01-PLAN.md
Normal file
215
.planning/phases/02-authentication-foundation/02-01-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<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"`, `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.
|
||||
</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 '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
|
||||
</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>
|
||||
218
.planning/phases/02-authentication-foundation/02-02-PLAN.md
Normal file
218
.planning/phases/02-authentication-foundation/02-02-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<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()` 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.
|
||||
</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 'redactHeader(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` for 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`
|
||||
- `./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>
|
||||
200
.planning/phases/02-authentication-foundation/02-03-PLAN.md
Normal file
200
.planning/phases/02-authentication-foundation/02-03-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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/androidMain/kotlin/dev/ulfrx/recipe/MainActivity.kt
|
||||
@iosApp/iosApp/iOSApp.swift
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Define OidcClient and secure store common 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)
|
||||
</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>
|
||||
<action>
|
||||
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.
|
||||
</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`
|
||||
- `./gradlew :composeApp:jvmTest` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Common auth platform seams exist with testable store semantics and exact scope contract.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement Android and iOS AppAuth actuals with explicit secure storage</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>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</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64</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 -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
|
||||
</acceptance_criteria>
|
||||
<done>Mobile targets compile with AppAuth login/refresh/logout and explicit secure AuthState persistence.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add JVM and Wasm target 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/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt</files>
|
||||
<action>
|
||||
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.
|
||||
</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 'NotImplementedError("Wasm OIDC: v2")' composeApp/src/webMain/kotlin/dev/ulfrx/recipe/auth/OidcClient.wasmJs.kt`
|
||||
- `./gradlew :composeApp:jvmTest :composeApp:compileKotlinWasmJs` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Secondary targets compile without expanding Phase 2 scope into real Desktop/Wasm OIDC.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Run `./gradlew :composeApp:jvmTest :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinIosSimulatorArm64 :composeApp:compileKotlinWasmJs`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
AUTH-01/AUTH-02 platform primitives exist: native AppAuth login/refresh/logout compiles, secure stores are explicit, and secondary target stubs match decisions.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-authentication-foundation/02-03-SUMMARY.md`.
|
||||
</output>
|
||||
193
.planning/phases/02-authentication-foundation/02-04-PLAN.md
Normal file
193
.planning/phases/02-authentication-foundation/02-04-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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-02-SUMMARY.md
|
||||
@.planning/phases/02-authentication-foundation/02-03-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-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 |
|
||||
</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-04-SUMMARY.md`.
|
||||
</output>
|
||||
202
.planning/phases/02-authentication-foundation/02-05-PLAN.md
Normal file
202
.planning/phases/02-authentication-foundation/02-05-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<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-04-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-05-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-05-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-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 |
|
||||
</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-05-SUMMARY.md`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user