Add authentication

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 015d8d51d0
commit 995bdd5ae6
92 changed files with 8140 additions and 208 deletions

View File

@@ -0,0 +1,224 @@
---
phase: 02-authentication-foundation
plan: 02
type: execute
wave: 2
depends_on: [02-01]
files_modified:
- server/src/main/resources/application.conf
- server/src/main/resources/db/migration/V1__users.sql
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt
- server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt
autonomous: true
requirements: [AUTH-03, AUTH-06]
must_haves:
truths:
- "GET /api/v1/me with a valid Authentik-style token returns the JIT-provisioned user record"
- "GET /api/v1/me with missing, expired, wrong-issuer, wrong-audience, or blank-sub token returns 401"
- "First valid request creates a users row keyed by OIDC sub; later request updates email/display_name for the same sub"
- "Authorization headers and bearer token values are not logged"
artifacts:
- path: "server/src/main/resources/db/migration/V1__users.sql"
provides: "users table per D-24"
contains: "CREATE TABLE users"
- path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt"
provides: "Ktor jwt(\"authentik\") verifier with issuer, audience, leeway, JWKS cache/rate limit, non-empty sub"
exports: ["configureAuthentication"]
- path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt"
provides: "Exposed DSL JIT user upsert by sub per D-25/D-26"
exports: ["PrincipalResolver"]
- path: "server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt"
provides: "Protected /api/v1/me route per D-27"
exports: ["meRoute"]
key_links:
- from: "server/src/main/kotlin/dev/ulfrx/recipe/Application.kt"
to: "server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt"
via: "install Authentication before route registration"
pattern: "configureAuthentication"
- from: "server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt"
to: "server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt"
via: "authenticated JWT principal resolves to users row"
pattern: "resolve"
---
<objective>
Implement the Ktor server authentication boundary: Authentik JWT validation, JIT user provisioning, and the protected `/api/v1/me` endpoint.
Purpose: satisfy AUTH-03 and AUTH-06 while establishing safe server auth patterns for Phase 3 household scoping.
Output: Flyway users migration, auth plugin, resolver, route, and negative JWT/JIT tests.
</objective>
<execution_context>
@/Users/rwilk/.codex/get-shit-done/workflows/execute-plan.md
@/Users/rwilk/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/REQUIREMENTS.md
@.planning/ROADMAP.md
@.planning/phases/02-authentication-foundation/02-CONTEXT.md
@.planning/phases/02-authentication-foundation/02-RESEARCH.md
@.planning/phases/02-authentication-foundation/02-VALIDATION.md
@.planning/phases/02-authentication-foundation/02-PATTERNS.md
@.planning/phases/02-authentication-foundation/02-01-SUMMARY.md
@AGENTS.md
@server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
@server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
@server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create JWT validation tests before auth implementation</name>
<read_first>
- server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt
- .planning/phases/02-authentication-foundation/02-VALIDATION.md (Wave 0 server tests)
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-21, D-22, D-23)
</read_first>
<files>server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt, server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt</files>
<behavior>
- No Authorization header returns 401.
- Expired token returns 401.
- Wrong issuer returns 401.
- Wrong audience returns 401.
- Blank `sub` returns 401.
- Valid RS256 test token returns 200 from a protected test route.
</behavior>
<action>
Create `JwtTestSupport` to generate an RSA keypair, expose a local JWKS endpoint in `testApplication`, and mint RS256 JWTs with configurable `iss`, `aud`, `sub`, `email`, `name`, and expiry.
Create `AuthJwtTest` that installs `ContentNegotiation`, `configureAuthentication(AuthConfig(...test issuer/audience/jwks...))`, and a protected test route under `authenticate("authentik")`. Tests must assert the status codes listed in `<behavior>`. Keep tests independent of Postgres and `Database.migrate`.
These tests should fail before `AuthPlugin.kt` exists; then continue to Task 2.
</action>
<verify>
<automated>./gradlew :server:test --tests "*AuthJwtTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'wrong audience' server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt`
- `grep -q 'blank sub' server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt`
- `grep -q 'RS256' server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt`
- After Task 2, `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0
</acceptance_criteria>
<done>JWT negative coverage exists for AUTH-03 and blocks wrong-audience/issuer regressions.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement AuthConfig, JWT plugin, logging redaction, and verify Exposed suspend API</name>
<read_first>
- server/src/main/resources/application.conf
- server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- .planning/phases/02-authentication-foundation/02-RESEARCH.md (Pitfall 4 Exposed API drift)
</read_first>
<files>server/src/main/resources/application.conf, server/src/main/kotlin/dev/ulfrx/recipe/Application.kt, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt</files>
<action>
Add `oidc { issuer, audience, jwksUrl, leewaySeconds }` to `application.conf` with env overrides `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL` and default leeway `30`.
Create `AuthConfig.fromApplicationConfig(config)` and `configureAuthentication(authConfig: AuthConfig = AuthConfig.fromApplicationConfig(environment.config))`. `AuthPlugin.kt` must install `jwt("authentik")` using `JwkProviderBuilder(jwksUrl or issuer).cached(10, 15, TimeUnit.MINUTES).rateLimited(10, 1, TimeUnit.MINUTES)`, `.withIssuer(issuer)`, `.withAudience(audience)`, `.acceptLeeway(30)`, and a validate block rejecting null/blank `sub`.
Install `CallLogging` in `Application.module()` using Ktor 3.4.1 APIs only. Configure `format { call -> "${call.request.httpMethod.value} ${call.request.path()} -> ${call.response.status()?.value ?: "-"}" }` so request/response method, path, and status are logged but headers are never included. Do not use `redactHeader(...)`; that API is not available on Ktor server `CallLoggingConfig` in the pinned Ktor 3.4.1 dependency. Never log token bodies or raw Authorization headers.
Before implementing Task 3 DB code, verify the pinned Exposed suspend transaction API/import against the dependency used by this repo. Run:
`./gradlew :server:dependencyInsight --dependency exposed-jdbc --configuration runtimeClasspath`
Then inspect IDE/Gradle source or compile probe and use whichever import compiles for pinned Exposed: expected for the chosen catalog version is `org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction`; if the pinned version requires `suspendTransaction`, use that exact import and note it in `02-02-SUMMARY.md`. Do not use blocking `transaction {}` inside suspend route code.
</action>
<verify>
<automated>./gradlew :server:test --tests "*AuthJwtTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'OIDC_ISSUER' server/src/main/resources/application.conf`
- `grep -q 'jwt("authentik")' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `grep -q 'withAudience' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `grep -q 'acceptLeeway(30' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `grep -q 'rateLimited(10, 1, TimeUnit.MINUTES)' server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt`
- `grep -q 'install(CallLogging)' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `grep -q 'format { call ->' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `! grep -q 'redactHeader' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `! grep -q 'HttpHeaders.Authorization' server/src/main/kotlin/dev/ulfrx/recipe/Application.kt`
- `./gradlew :server:test --tests "*AuthJwtTest*"` exits 0
</acceptance_criteria>
<done>Server rejects invalid JWTs, accepts valid Authentik-style JWTs, and redacts Authorization headers.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Add users migration, Exposed resolver, /api/v1/me route, and JIT tests</name>
<read_first>
- shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/dto/MeResponse.kt
- .planning/phases/02-authentication-foundation/02-CONTEXT.md (D-24 through D-27)
- server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
- server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt
</read_first>
<files>server/src/main/resources/db/migration/V1__users.sql, server/src/main/kotlin/dev/ulfrx/recipe/Database.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt, server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt, server/src/main/kotlin/dev/ulfrx/recipe/Application.kt, server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt</files>
<action>
Create `V1__users.sql` exactly with `users(id UUID PRIMARY KEY DEFAULT gen_random_uuid(), sub TEXT NOT NULL UNIQUE, email TEXT NOT NULL, display_name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())` plus `CREATE INDEX users_sub_idx ON users(sub);`.
Add Exposed `UsersTable` using DSL only. Add `Database.connect(app)` using Hikari or direct Exposed connection after Flyway migration.
Implement `PrincipalResolver.resolve(jwtPrincipal)` as a suspend function that extracts non-empty `sub`, `email`, and `name`/`preferred_username` fallback, then performs atomic Postgres upsert by `sub` updating `email`, `display_name`, `updated_at = now()` and returning a `User`/`MeResponse`. Use the verified suspend transaction import from Task 2. Do not select-then-insert.
Add `meRoute(principalResolver)` under `authenticate("authentik") { get("/api/v1/me") { ... } }`. Wire route from `configureRouting`.
Create `MeRouteTest` with an explicit runnable PostgreSQL test database using Testcontainers, not ambient local Postgres. Use `org.testcontainers.containers.PostgreSQLContainer` with image `postgres:16`, start it in the test fixture, set the server/database config to the container JDBC URL, username, and password before installing routes, and stop it after tests. Run Flyway against the container before assertions. Tests must cover: valid token creates row, second token same `sub` updates email/display name without duplicating, and valid response body contains `id`, `sub`, `email`, `displayName`.
</action>
<verify>
<automated>./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"</automated>
</verify>
<acceptance_criteria>
- `grep -q 'CREATE TABLE users' server/src/main/resources/db/migration/V1__users.sql`
- `grep -q 'sub TEXT NOT NULL UNIQUE' server/src/main/resources/db/migration/V1__users.sql`
- `! grep -R 'org.jetbrains.exposed.dao' server/src/main/kotlin/dev/ulfrx/recipe/auth`
- `! grep -R 'transaction {' server/src/main/kotlin/dev/ulfrx/recipe/auth`
- `grep -q 'ON CONFLICT (sub) DO UPDATE' server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt`
- `grep -q 'get("/api/v1/me")' server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt`
- `grep -q 'PostgreSQLContainer' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt`
- `grep -q 'postgres:16' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt`
- `grep -q 'Flyway' server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt`
- `./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"` exits 0
</acceptance_criteria>
<done>`/api/v1/me` validates tokens, JIT-provisions users atomically, and returns the shared DTO.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client -> Ktor | Untrusted bearer token arrives in Authorization header |
| Ktor -> Authentik JWKS | Server fetches signing keys from Authentik |
| Ktor -> Postgres | Authenticated claims become persisted user rows |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-02-01 | Elevation | JWT verifier | mitigate | Validate issuer, audience, expiry, RS256 signature, 30s leeway, and non-empty `sub`; negative tests for wrong audience/issuer/blank sub |
| T-02-02-02 | Denial of Service | JWKS provider | mitigate | Configure cache size 10 / 15 min and rate limit 10 per minute per D-22 |
| T-02-02-03 | Information Disclosure | server logs | mitigate | Ktor CallLogging redacts Authorization and code never logs bearer token bodies |
| T-02-02-04 | Tampering | JIT provisioning | mitigate | Atomic upsert on unique `sub`; no client-supplied user ID |
| T-02-02-05 | Repudiation | user updates | accept | Phase 2 records current `updated_at`; full audit log is out of scope for small household v1 |
</threat_model>
<verification>
Run `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` and then `./gradlew :server:test`.
</verification>
<success_criteria>
AUTH-03 and AUTH-06 are satisfied: valid tokens return `/api/v1/me`, invalid tokens return 401, and user rows are created/updated by OIDC `sub`.
</success_criteria>
<output>
After completion, create `.planning/phases/02-authentication-foundation/02-02-SUMMARY.md`.
</output>