---
phase: 02-authentication-foundation
plan: 02
subsystem: auth
tags: [ktor, jwt, authentik, jwks, postgres, flyway, exposed, testcontainers]

# Dependency graph
requires:
  - phase: 02-01
    provides: shared auth DTOs, dependency aliases, and Authentik setup context
provides:
  - Authentik-style JWT validation with issuer, audience, expiry, RS256 signature, JWKS caching, and non-empty sub enforcement
  - Flyway users table migration keyed by OIDC sub
  - Exposed DSL JIT user upsert and protected GET /api/v1/me route
  - Server auth integration tests for JWT rejection and user provisioning
affects: [phase-03-households, server-auth, principal-resolution, api-v1]

# Tech tracking
tech-stack:
  added: [ktor-server-auth-jwt, jwks-rsa, hikari, testcontainers-postgresql]
  patterns: [Ktor jwt("authentik") provider, cached/rate-limited JWKS provider, newSuspendedTransaction for route DB work, Postgres ON CONFLICT upsert]

key-files:
  created:
    - server/src/main/resources/db/migration/V1__users.sql
    - 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
  modified:
    - server/src/main/resources/application.conf
    - server/src/main/kotlin/dev/ulfrx/recipe/Database.kt
    - server/src/main/kotlin/dev/ulfrx/recipe/Application.kt
    - server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt

key-decisions:
  - "Pinned Exposed runtime is 0.55.0; the suspend transaction import used is org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction."
  - "PrincipalResolver uses Postgres INSERT ... ON CONFLICT ... RETURNING via Exposed exec because the resolver must atomically upsert and return the generated user id."
  - "CallLogging uses a custom method/path/status format and omits all headers because Ktor 3.4.1 server CallLogging has no redactHeader API."

patterns-established:
  - "Protected server routes sit inside authenticate(\"authentik\") and resolve JWTPrincipal through PrincipalResolver before returning user data."
  - "Server-side user identity is derived only from JWT claims, never request bodies."
  - "Server auth tests use in-process RSA/JWKS support for JWT verifier coverage and Testcontainers Postgres for JIT provisioning coverage."

requirements-completed: [AUTH-03, AUTH-06]

# Metrics
duration: 13min
completed: 2026-04-28
---

# Phase 02 Plan 02: Server JWT Validation and JIT Users Summary

**Ktor Authentik JWT validation with cached JWKS, atomic Postgres user provisioning by OIDC sub, and protected `/api/v1/me`.**

## Performance

- **Duration:** 13 min for final executor verification and summary; task commits already existed on this branch when this executor resumed.
- **Started:** 2026-04-28T11:18:15Z
- **Completed:** 2026-04-28T11:31:08Z
- **Tasks:** 3 completed
- **Files modified:** 13 code/config/test files plus this summary

## Accomplishments

- Added JWT validation coverage for missing, expired, wrong-issuer, wrong-audience, blank-sub, and valid RS256 tokens.
- Installed Ktor `jwt("authentik")` with issuer/audience checks, 30-second max leeway, non-empty `sub`, cached JWKS, and rate limiting.
- Added `users` Flyway migration, Exposed table mapping, Hikari-backed Exposed connection, atomic JIT upsert by `sub`, and protected `/api/v1/me`.
- Added Testcontainers Postgres integration coverage proving first request creates a user row and later requests update mutable claims without duplication.

## Task Commits

Each task was committed atomically:

1. **Task 1: Create JWT validation tests before auth implementation** - `614b57c` (`test`)
2. **Task 2: Implement AuthConfig, JWT plugin, logging redaction, and verify Exposed suspend API** - `36c1b2c` (`feat`)
3. **Task 3: Add users migration, Exposed resolver, /api/v1/me route, and JIT tests** - `8cf112a` (`feat`)

No tracked file deletions were present in the task commits.

## Files Created/Modified

- `server/src/main/resources/application.conf` - Adds OIDC issuer/audience/JWKS/leeway config with env overrides.
- `server/src/main/resources/db/migration/V1__users.sql` - Creates the `users` table and `users_sub_idx`.
- `server/src/main/kotlin/dev/ulfrx/recipe/Database.kt` - Adds Hikari-backed Exposed connection after Flyway migration.
- `server/src/main/kotlin/dev/ulfrx/recipe/Application.kt` - Installs safe CallLogging, authentication, DB migration/connection, and auth route wiring.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt` - Reads server OIDC config from HOCON.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt` - Installs the Authentik JWT verifier.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt` - Exposed DSL mapping for `users`.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt` - Resolves `JWTPrincipal` to `MeResponse` through atomic upsert.
- `server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt` - Provides protected `GET /api/v1/me`.
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt` - Generates RSA keys, JWKS provider, and configurable RS256 JWTs for tests.
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt` - Covers JWT validation positive and negative cases.
- `server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt` - Covers JIT provisioning against Testcontainers Postgres.
- `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt` - Keeps `/health` test wiring compatible with authenticated route registration.

## Decisions Made

- Used `newSuspendedTransaction` from `org.jetbrains.exposed.sql.transactions.experimental` after confirming `org.jetbrains.exposed:exposed-jdbc:0.55.0`.
- Used raw SQL through Exposed `exec` for `INSERT ... ON CONFLICT ... RETURNING`, because the resolver needs the returned row and generated UUID in one atomic operation.
- Kept logging to method, path, and status only; no header logging or bearer-token redaction API is used.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 1 - Bug] Kept `/health` test route registration compatible with authenticated routes**
- **Found during:** Task 3
- **Issue:** Once `configureRouting()` registered `meRoute`, tests that installed routing without Authentication would fail route setup.
- **Fix:** Updated `ApplicationTest` to install the test JWT authentication plugin before calling `configureRouting()`.
- **Files modified:** `server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt`
- **Verification:** `./gradlew :server:test`
- **Committed in:** `8cf112a`

**2. [Rule 3 - Blocking] Used Exposed `StatementType.SELECT` for Postgres upsert returning rows**
- **Found during:** Task 3
- **Issue:** `INSERT ... RETURNING` must be executed as a result-producing statement; otherwise Postgres reports that a result was returned when none was expected.
- **Fix:** Added `explicitStatementType = StatementType.SELECT` to the Exposed `exec` call.
- **Files modified:** `server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt`
- **Verification:** `./gradlew :server:test --tests "*MeRouteTest*" --tests "*AuthJwtTest*"`
- **Committed in:** `8cf112a`

---

**Total deviations:** 2 auto-fixed (1 bug, 1 blocking issue)
**Impact on plan:** Both fixes were required for the planned tests and route behavior. No extra feature scope was added.

## Issues Encountered

- Testcontainers Postgres made the first filtered test run take several minutes while the container image/runtime initialized. Subsequent server test runs completed from cache.

## Authentication Gates

None.

## Known Stubs

None.

## User Setup Required

None for this plan. Real Authentik provider setup remains covered by the Phase 2 setup documentation from plan `02-01`.

## Verification

- `./gradlew :server:dependencyInsight --dependency exposed-jdbc --configuration runtimeClasspath` - passed; Exposed JDBC version is `0.55.0`.
- `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` - passed.
- `./gradlew :server:test` - passed.
- Task acceptance greps for OIDC config, JWT verifier settings, logging safety, migration shape, no DAO imports, no blocking `transaction {}` in auth code, `/api/v1/me`, Testcontainers, `postgres:16`, and Flyway all passed.

## Next Phase Readiness

Phase 3 can extend `PrincipalResolver` from user identity to household-scoped principal resolution. The server now has the stable `users.sub` anchor and `/api/v1/me` boundary that Phase 3 onboarding and household membership can build on.

## Self-Check: PASSED

- Created/modified key files exist.
- Task commits found: `614b57c`, `36c1b2c`, `8cf112a`.
- Required verification commands passed.
- No unplanned tracked file deletions were detected in task commits.

---
*Phase: 02-authentication-foundation*
*Completed: 2026-04-28*
