Add authentication

This commit is contained in:
2026-04-27 19:28:57 +02:00
parent 6684b7179d
commit e0af5f4053
92 changed files with 8140 additions and 208 deletions

View File

@@ -1,20 +1,22 @@
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
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.netty.EngineMain
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.httpMethod
import io.ktor.server.request.path
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable
fun main() {
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}
fun main(args: Array<String>): Unit = EngineMain.main(args)
@Serializable
private data class Health(
@@ -22,17 +24,28 @@ private data class Health(
)
fun Application.module() {
install(CallLogging) {
// 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 ?: "-"}"
}
}
install(ContentNegotiation) {
json()
}
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)
}
}

View File

@@ -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.slf4j.LoggerFactory
import org.jetbrains.exposed.sql.Database as ExposedDatabase
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,48 @@ 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(),
)
}

View File

@@ -0,0 +1,42 @@
package dev.ulfrx.recipe.auth
import io.ktor.server.config.ApplicationConfig
/**
* Server OIDC configuration (D-12, D-21..D-22).
*
* Values come from `application.conf` (`oidc { ... }`) overridden by
* `OIDC_ISSUER` / `OIDC_AUDIENCE` / `OIDC_JWKS_URL` env vars at deploy time. The
* pinned defaults match `Constants.OIDC_ISSUER` / `Constants.OIDC_CLIENT_ID` so
* dev runs against the documented Authentik provider playbook
* (`docs/authentik-setup.md`) without extra wiring.
*
* `jwksUrl` is required: D-22 mandates a `JwkProvider` with explicit cache + rate
* limits. If the deployer omits it, `fromApplicationConfig` derives the standard
* Authentik path (`{issuer}jwks/`). The trailing-slash invariant on `issuer` is
* enforced by [Constants.OIDC_ISSUER] / `application.conf`.
*/
public data class AuthConfig(
val issuer: String,
val audience: String,
val jwksUrl: String,
val leewaySeconds: Long = 30L,
) {
public companion object {
public fun fromApplicationConfig(config: ApplicationConfig): AuthConfig {
val issuer = config.property("oidc.issuer").getString()
val audience = config.property("oidc.audience").getString()
val jwksUrl =
config.propertyOrNull("oidc.jwksUrl")?.getString()?.takeIf { it.isNotBlank() }
?: "${issuer.trimEnd('/')}/jwks/"
val leewaySeconds =
config.propertyOrNull("oidc.leewaySeconds")?.getString()?.toLongOrNull() ?: 30L
return AuthConfig(
issuer = issuer,
audience = audience,
jwksUrl = jwksUrl,
leewaySeconds = leewaySeconds,
)
}
}
}

View File

@@ -0,0 +1,52 @@
package dev.ulfrx.recipe.auth
import com.auth0.jwk.JwkProvider
import com.auth0.jwk.JwkProviderBuilder
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.jwt.JWTPrincipal
import io.ktor.server.auth.jwt.jwt
import java.net.URI
import java.util.concurrent.TimeUnit
/**
* Installs the `jwt("authentik")` Ktor authentication provider per D-21..D-23.
*
* Production callers use the no-arg form, which reads [AuthConfig] from
* `application.conf` and builds a cached, rate-limited [JwkProvider] from the
* issuer's JWKS endpoint (D-22: `cached(10, 15, MINUTES)` + `rateLimited(10, 1, MINUTES)`).
*
* Tests override [jwkProvider] with [JwtTestSupport.provider] to keep verification
* in-process — `testApplication` has no usable network port, so the URL-based
* builder isn't viable.
*
* Validate block enforces D-21's non-empty `sub` rule. Returning `null` from
* `validate` produces 401 — Ktor's default challenge handler is sufficient; we
* deliberately avoid logging Authorization headers or token bodies (D-23).
*/
public fun Application.configureAuthentication(
authConfig: AuthConfig = AuthConfig.fromApplicationConfig(environment.config),
jwkProvider: JwkProvider =
JwkProviderBuilder(URI(authConfig.jwksUrl).toURL())
.cached(10, 15, TimeUnit.MINUTES)
.rateLimited(10, 1, TimeUnit.MINUTES)
.build(),
) {
install(Authentication) {
jwt("authentik") {
realm = "recipe"
verifier(jwkProvider, authConfig.issuer) {
withIssuer(authConfig.issuer)
withAudience(authConfig.audience)
// 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
if (sub.isNullOrBlank()) null else JWTPrincipal(credential.payload)
}
}
}
}

View File

@@ -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<JWTPrincipal>()
?: return@get call.respond(HttpStatusCode.Unauthorized)
val me = principalResolver.resolve(jwt)
call.respond(me)
}
}
}

View File

@@ -0,0 +1,78 @@
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")
}
}
}

View File

@@ -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)
}