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