Files
recipe/.planning/phases/02-authentication-foundation/02-02-PLAN.md
2026-04-29 20:54:13 +02:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-authentication-foundation 02 execute 2
02-01
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
true
AUTH-03
AUTH-06
truths artifacts key_links
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
path provides contains
server/src/main/resources/db/migration/V1__users.sql users table per D-24 CREATE TABLE users
path provides exports
server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt Ktor jwt("authentik") verifier with issuer, audience, leeway, JWKS cache/rate limit, non-empty sub
configureAuthentication
path provides exports
server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt Exposed DSL JIT user upsert by sub per D-25/D-26
PrincipalResolver
path provides exports
server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt Protected /api/v1/me route per D-27
meRoute
from to via pattern
server/src/main/kotlin/dev/ulfrx/recipe/Application.kt server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt install Authentication before route registration configureAuthentication
from to via pattern
server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt authenticated JWT principal resolves to users row 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.

<execution_context> @/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md @/Users/rwilk/.codex/get-shit-done/templates/summary.md </execution_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 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 `<behavior>`. 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()` using Ktor 3.4.1 APIs only. Configure `format { call -> "${call.request.httpMethod.value} ${call.request.path()} -> ${call.response.status()?.value ?: "-"}" }` so request/response method, path, and status are logged but headers are never included. Do not use `redactHeader(...)`; that API is not available on Ktor server `CallLoggingConfig` in the pinned Ktor 3.4.1 dependency. Never log token bodies or raw Authorization headers.

Before implementing Task 3 DB code, verify the pinned Exposed suspend transaction API/import against the dependency used by this repo. Run:
`./gradlew :server:dependencyInsight --dependency exposed-jdbc --configuration runtimeClasspath`
Then inspect IDE/Gradle source or compile probe and use whichever import compiles for pinned Exposed: expected for the chosen catalog version is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, use that exact import and note it in `02-02-SUMMARY.md`. Do not use blocking `transaction {}` inside suspend route code.
./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 'install(CallLogging)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` - `grep -q 'format { call ->' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` - `! grep -q 'redactHeader' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` - `! grep -q 'HttpHeaders.Authorization' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` - `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0 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` with an explicit runnable PostgreSQL test database using Testcontainers, not ambient local Postgres. Use `org.testcontainers.containers.PostgreSQLContainer` with image `postgres:16`, start it in the test fixture, set the server/database config to the container JDBC URL, username, and password before installing routes, and stop it after tests. Run Flyway against the container before assertions. Tests must cover: valid token creates row, second token same `sub` updates email/display name without duplicating, and valid response body contains `id`, `sub`, `email`, `displayName`.
./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` - `grep -q 'PostgreSQLContainer' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` - `grep -q 'postgres:16' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` - `grep -q 'Flyway' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` - `./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"` exits 0 `/api/v1/me` validates tokens, JIT-provisions users atomically, and returns the shared DTO.

<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>
Run `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` and then `./gradlew :server:test`.

<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>

After completion, create `.planning/phases/02-authentication-foundation/02-02-SUMMARY.md`.