@.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 `<behavior>`. Keep tests independent of Postgres and `Database.migrate`.
These tests should fail before `AuthPlugin.kt` exists; then continue to Task 2.
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.
./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 '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
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` 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`.
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>
Run `./gradlew :server:test --tests "*AuthJwtTest*" --tests "*MeRouteTest*"` and then `./gradlew :server:test`.
<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>
After completion, create `.planning/phases/02-authentication-foundation/02-02-SUMMARY.md`.