diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt b/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt index 6b7a01e..617f9fa 100644 --- a/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt +++ b/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt @@ -1,6 +1,8 @@ package dev.ulfrx.recipe +import dev.ulfrx.recipe.auth.PrincipalResolver import dev.ulfrx.recipe.auth.configureAuthentication +import dev.ulfrx.recipe.auth.meRoute import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.install @@ -27,9 +29,9 @@ private data class Health( fun Application.module() { install(CallLogging) { - // Method + path + status only — never headers (D-23). Ktor 3.4.1 does not expose - // `redactHeader(...)` on CallLoggingConfig, so the only safe approach is to omit - // header data from the format entirely. Bearer tokens never reach the log. + // Method + path + status only — never headers (D-23). Ktor 3.4.1's CallLoggingConfig + // doesn't expose a header-redaction API, so the only safe approach is to omit header + // data from the format entirely. Bearer tokens never reach the log. format { call -> "${call.request.httpMethod.value} ${call.request.path()} -> ${call.response.status()?.value ?: "-"}" } @@ -39,13 +41,15 @@ fun Application.module() { } configureAuthentication() Database.migrate(this) - configureRouting() + Database.connect(this) + configureRouting(PrincipalResolver()) } -fun Application.configureRouting() { +fun Application.configureRouting(principalResolver: PrincipalResolver = PrincipalResolver()) { routing { get("/health") { call.respond(Health(status = "ok")) } + meRoute(principalResolver) } } diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/Database.kt b/server/src/main/kotlin/dev/ulfrx/recipe/Database.kt index 5876dd6..d56846d 100644 --- a/server/src/main/kotlin/dev/ulfrx/recipe/Database.kt +++ b/server/src/main/kotlin/dev/ulfrx/recipe/Database.kt @@ -1,28 +1,18 @@ package dev.ulfrx.recipe +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource import io.ktor.server.application.Application import org.flywaydb.core.Flyway +import org.jetbrains.exposed.sql.Database as ExposedDatabase import org.slf4j.LoggerFactory object Database { private val log = LoggerFactory.getLogger(Database::class.java) fun migrate(app: Application) { - val url = - app.environment.config - .property("database.url") - .getString() - val user = - app.environment.config - .property("database.user") - .getString() - val password = - app.environment.config - .property("database.password") - .getString() - + val (url, user, password) = readConfig(app) log.info("Connecting to {} as {} and running Flyway migrations", url, user) - runCatching { Flyway .configure() @@ -38,4 +28,35 @@ object Database { throw IllegalStateException("Database unreachable or migration failed", ex) } } + + /** + * Wire Exposed to a Hikari pool against the same JDBC URL Flyway used. + * Must be called AFTER [migrate] so the schema is in place before the first + * Exposed transaction runs (Phase 2 D-26). + */ + fun connect(app: Application) { + val (url, user, password) = readConfig(app) + val ds = + HikariDataSource( + HikariConfig().apply { + jdbcUrl = url + username = user + setPassword(password) + maximumPoolSize = 10 + minimumIdle = 2 + poolName = "recipe-pool" + }, + ) + ExposedDatabase.connect(ds) + log.info("Exposed connected via Hikari pool '{}'", ds.poolName) + } + + private data class Conf(val url: String, val user: String, val password: String) + + private fun readConfig(app: Application): Conf = + Conf( + url = app.environment.config.property("database.url").getString(), + user = app.environment.config.property("database.user").getString(), + password = app.environment.config.property("database.password").getString(), + ) } diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt b/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt index 3043138..ec4ea70 100644 --- a/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt +++ b/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt @@ -39,7 +39,9 @@ public fun Application.configureAuthentication( verifier(jwkProvider, authConfig.issuer) { withIssuer(authConfig.issuer) withAudience(authConfig.audience) - acceptLeeway(authConfig.leewaySeconds) + // D-21: clock-skew tolerance pinned at 30 seconds (acceptLeeway(30 seconds)). + // Honor the AuthConfig override if set lower for tests, but never exceed 30. + acceptLeeway(authConfig.leewaySeconds.coerceAtMost(30L)) } validate { credential -> val sub = credential.payload.subject diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt b/server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt new file mode 100644 index 0000000..3e45bbf --- /dev/null +++ b/server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt @@ -0,0 +1,28 @@ +package dev.ulfrx.recipe.auth + +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.principal +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get + +/** + * Protected `GET /api/v1/me` route per D-27. + * + * The `authenticate("authentik")` block must be installed by [configureAuthentication] + * before this route is registered. JIT user provisioning happens inside [PrincipalResolver] + * on every authenticated call so claim drift propagates without an explicit migration. + */ +public fun Route.meRoute(principalResolver: PrincipalResolver) { + authenticate("authentik") { + get("/api/v1/me") { + val jwt = + call.principal() + ?: return@get call.respond(HttpStatusCode.Unauthorized) + val me = principalResolver.resolve(jwt) + call.respond(me) + } + } +} diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt b/server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt new file mode 100644 index 0000000..f71b99f --- /dev/null +++ b/server/src/main/kotlin/dev/ulfrx/recipe/auth/PrincipalResolver.kt @@ -0,0 +1,74 @@ +package dev.ulfrx.recipe.auth + +import dev.ulfrx.recipe.shared.dto.MeResponse +import io.ktor.server.auth.jwt.JWTPrincipal +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.sql.TextColumnType +import org.jetbrains.exposed.sql.statements.StatementType +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction + +/** + * Resolves a Ktor [JWTPrincipal] to a persisted [MeResponse] via JIT upsert (D-25). + * + * One row per OIDC `sub`; email + display_name refresh on every authenticated + * request so claim drift in Authentik propagates without an explicit migration. + * + * Implementation detail: Postgres `INSERT ... ON CONFLICT (sub) DO UPDATE ... + * RETURNING *` is the atomic upsert per D-25. We issue raw SQL via Exposed's + * `exec()` so we can use `RETURNING` (Exposed 0.55.0 DSL `upsert` returns the + * insert count, not the row, and we need the generated id and updated_at). DAO + * is forbidden by CLAUDE.md #5; we use `exec` (DSL) not the DAO API. + * + * `newSuspendedTransaction(Dispatchers.IO)` is mandatory: a plain blocking + * Exposed transaction inside a suspend route exhausts the connection pool + * (CLAUDE.md #6, PITFALLS.md #5/#6). + */ +public class PrincipalResolver { + public suspend fun resolve(principal: JWTPrincipal): MeResponse { + val sub = + principal.payload.subject?.takeIf { it.isNotBlank() } + ?: error("PrincipalResolver invoked with blank sub — should be blocked by AuthPlugin.validate") + val email = principal.payload.getClaim("email")?.asString().orEmpty() + val nameClaim = principal.payload.getClaim("name")?.asString() + val preferredUsername = principal.payload.getClaim("preferred_username")?.asString() + val displayName = nameClaim ?: preferredUsername ?: email.ifBlank { sub } + + return newSuspendedTransaction(Dispatchers.IO) { + var row: MeResponse? = null + // INSERT...RETURNING returns a ResultSet, so we must mark this as a SELECT + // statement type — Exposed's `exec` defaults to executeUpdate() for INSERT + // statements which Postgres rejects with "A result was returned when none + // was expected." + exec( + stmt = + """ + INSERT INTO users (sub, email, display_name) + VALUES (?, ?, ?) + ON CONFLICT (sub) DO UPDATE + SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name, + updated_at = now() + RETURNING id, sub, email, display_name + """.trimIndent(), + args = + listOf( + TextColumnType() to sub, + TextColumnType() to email, + TextColumnType() to displayName, + ), + explicitStatementType = StatementType.SELECT, + ) { rs -> + if (rs.next()) { + row = + MeResponse( + id = rs.getObject("id").toString(), + sub = rs.getString("sub"), + email = rs.getString("email"), + displayName = rs.getString("display_name"), + ) + } + } + row ?: error("Upsert RETURNING produced no row for sub=$sub") + } + } +} diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt b/server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt new file mode 100644 index 0000000..4d4fc17 --- /dev/null +++ b/server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt @@ -0,0 +1,21 @@ +package dev.ulfrx.recipe.auth + +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.timestamp + +/** + * Exposed DSL mapping for the Phase 2 `users` migration (D-24, D-26). + * + * DSL only — DAO is forbidden by CLAUDE.md #5. Phase 3 will add `households`, + * `memberships`, and `invites` tables; this table stays untouched at that point + * because identity is anchored on the OIDC `sub`. + */ +public object UsersTable : Table("users") { + public val id = uuid("id") + public val sub = text("sub") + public val email = text("email") + public val displayName = text("display_name") + public val createdAt = timestamp("created_at") + public val updatedAt = timestamp("updated_at") + override val primaryKey = PrimaryKey(id) +} diff --git a/server/src/main/resources/db/migration/V1__users.sql b/server/src/main/resources/db/migration/V1__users.sql new file mode 100644 index 0000000..95c0907 --- /dev/null +++ b/server/src/main/resources/db/migration/V1__users.sql @@ -0,0 +1,14 @@ +-- Phase 2 D-24: principal table for OIDC-resolved users. +-- Identity is the OIDC `sub` claim (UNIQUE); email/display_name are mutable claims +-- refreshed by the JIT upsert in PrincipalResolver. Phase 3 layers +-- households/memberships/invites on top of this; do NOT add household_id here. +CREATE TABLE 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() +); + +CREATE INDEX users_sub_idx ON users(sub); diff --git a/server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt b/server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt index edee832..4714e17 100644 --- a/server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt +++ b/server/src/test/kotlin/dev/ulfrx/recipe/ApplicationTest.kt @@ -1,5 +1,8 @@ package dev.ulfrx.recipe +import dev.ulfrx.recipe.auth.AuthConfig +import dev.ulfrx.recipe.auth.JwtTestSupport +import dev.ulfrx.recipe.auth.configureAuthentication import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode @@ -15,10 +18,24 @@ class ApplicationTest { @Test fun `health endpoint returns 200 with status ok`() = testApplication { + // Phase 2: configureRouting now wires `meRoute` under `authenticate("authentik")`, + // so the Authentication plugin must be installed before routing — otherwise the + // route DSL throws MissingApplicationPluginException at registration time. + // The verifier doesn't need to validate any tokens for this `/health` test, but + // the plugin must exist; reuse the test JWT support to keep the wiring honest. + val support = JwtTestSupport() application { install(ContentNegotiation) { json() } + configureAuthentication( + AuthConfig( + issuer = support.issuer, + audience = support.audience, + jwksUrl = "https://test-authentik.invalid/application/o/jwks/", + ), + support.provider, + ) configureRouting() } val response = client.get("/health") diff --git a/server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt b/server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt new file mode 100644 index 0000000..2a7805e --- /dev/null +++ b/server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt @@ -0,0 +1,145 @@ +package dev.ulfrx.recipe.auth + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import dev.ulfrx.recipe.shared.dto.MeResponse +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json +import org.flywaydb.core.Flyway +import org.jetbrains.exposed.sql.Database as ExposedDatabase +import org.junit.AfterClass +import org.junit.BeforeClass +import org.testcontainers.containers.PostgreSQLContainer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Integration test for `GET /api/v1/me` — exercises the full + * Auth → PrincipalResolver → Postgres path against a real database (AUTH-03, + * AUTH-06, D-25, D-27). + * + * Uses Testcontainers `postgres:16` rather than ambient localhost Postgres so + * the test is hermetic across dev machines / CI. Flyway runs once per JVM + * process before any test executes; Exposed is connected through Hikari to + * the container. + */ +class MeRouteTest { + companion object { + private val postgres = PostgreSQLContainer("postgres:16") + private lateinit var dataSource: HikariDataSource + private val json = Json { ignoreUnknownKeys = true } + + @JvmStatic + @BeforeClass + fun setUpClass() { + postgres.start() + Flyway + .configure() + .dataSource(postgres.jdbcUrl, postgres.username, postgres.password) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .load() + .migrate() + dataSource = + HikariDataSource( + HikariConfig().apply { + jdbcUrl = postgres.jdbcUrl + username = postgres.username + setPassword(postgres.password) + maximumPoolSize = 4 + poolName = "me-route-test-pool" + }, + ) + ExposedDatabase.connect(dataSource) + } + + @JvmStatic + @AfterClass + fun tearDownClass() { + dataSource.close() + postgres.stop() + } + } + + private fun authConfigFor(support: JwtTestSupport): AuthConfig = + AuthConfig( + issuer = support.issuer, + audience = support.audience, + jwksUrl = "https://test-authentik.invalid/application/o/jwks/", + leewaySeconds = 30, + ) + + @Test + fun `valid token creates user row and returns MeResponse`() = + testApplication { + val support = JwtTestSupport() + val resolver = PrincipalResolver() + application { + install(ContentNegotiation) { json() } + configureAuthentication(authConfigFor(support), support.provider) + routing { meRoute(resolver) } + } + val token = support.mint(sub = "auth0|user-create-123") + + val response = + client.get("/api/v1/me") { + header(HttpHeaders.Authorization, "Bearer $token") + } + + assertEquals(HttpStatusCode.OK, response.status) + val body = json.decodeFromString(MeResponse.serializer(), response.bodyAsText()) + assertEquals("auth0|user-create-123", body.sub) + assertEquals("test@example.invalid", body.email) + assertEquals("Test User", body.displayName) + assertTrue(body.id.isNotBlank(), "id should be a server-generated UUID, was: ${body.id}") + } + + @Test + fun `second token with same sub updates email and display name without duplicating`() = + testApplication { + val support = JwtTestSupport() + val resolver = PrincipalResolver() + application { + install(ContentNegotiation) { json() } + configureAuthentication(authConfigFor(support), support.provider) + routing { meRoute(resolver) } + } + + val first = + client.get("/api/v1/me") { + header( + HttpHeaders.Authorization, + "Bearer ${support.mint(sub = "auth0|user-update-1", email = "old@example.invalid", name = "Old Name")}", + ) + } + assertEquals(HttpStatusCode.OK, first.status) + val firstBody = json.decodeFromString(MeResponse.serializer(), first.bodyAsText()) + + val second = + client.get("/api/v1/me") { + header( + HttpHeaders.Authorization, + "Bearer ${support.mint(sub = "auth0|user-update-1", email = "new@example.invalid", name = "New Name")}", + ) + } + assertEquals(HttpStatusCode.OK, second.status) + val secondBody = json.decodeFromString(MeResponse.serializer(), second.bodyAsText()) + + assertEquals(firstBody.id, secondBody.id) // same row + assertEquals(firstBody.sub, secondBody.sub) + assertEquals("new@example.invalid", secondBody.email) + assertEquals("New Name", secondBody.displayName) + assertNotEquals(firstBody.email, secondBody.email) + } +}