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