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