feat(02-02): wire AuthConfig, JWT verifier, and CallLogging redaction

This commit is contained in:
2026-04-28 13:06:50 +02:00
parent 614b57c34d
commit 36c1b2c822
6 changed files with 146 additions and 35 deletions

View File

@@ -1,11 +1,15 @@
package dev.ulfrx.recipe
import dev.ulfrx.recipe.auth.configureAuthentication
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.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
@@ -22,9 +26,18 @@ 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.
format { call ->
"${call.request.httpMethod.value} ${call.request.path()} -> ${call.response.status()?.value ?: "-"}"
}
}
install(ContentNegotiation) {
json()
}
configureAuthentication()
Database.migrate(this)
configureRouting()
}

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,50 @@
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)
acceptLeeway(authConfig.leewaySeconds)
}
validate { credential ->
val sub = credential.payload.subject
if (sub.isNullOrBlank()) null else JWTPrincipal(credential.payload)
}
}
}
}

View File

@@ -16,3 +16,16 @@ database {
password = "recipe"
password = ${?DATABASE_PASSWORD}
}
oidc {
# Authentik OIDC issuer (trailing slash required — see Constants.OIDC_ISSUER / D-11).
issuer = "https://auth.example.invalid/application/o/recipe/"
issuer = ${?OIDC_ISSUER}
# Audience pinned to client_id per D-07.
audience = "recipe-app"
audience = ${?OIDC_AUDIENCE}
# Optional override; if blank, AuthConfig.fromApplicationConfig derives `${issuer}jwks/`.
jwksUrl = ""
jwksUrl = ${?OIDC_JWKS_URL}
leewaySeconds = "30"
}

View File

@@ -5,6 +5,7 @@ import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
@@ -27,33 +28,27 @@ class AuthJwtTest {
AuthConfig(
issuer = support.issuer,
audience = support.audience,
// jwksUrl is unused when a [jwkProvider] override is supplied — the value
// jwksUrl is unused when a JwkProvider override is supplied — the value
// just needs to be a syntactically-valid URL so AuthConfig invariants hold.
jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
leewaySeconds = 30,
)
private fun configure(
block: io.ktor.server.application.Application.(JwtTestSupport) -> Unit,
): Pair<JwtTestSupport, io.ktor.server.application.Application.() -> Unit> {
val support = JwtTestSupport()
return support to {
install(ContentNegotiation) { json() }
configureAuthentication(authConfigFor(support), support.provider)
routing {
authenticate("authentik") {
get("/protected") { call.respondText("ok") }
}
private fun Application.installProtectedRoute(support: JwtTestSupport) {
install(ContentNegotiation) { json() }
configureAuthentication(authConfigFor(support), support.provider)
routing {
authenticate("authentik") {
get("/protected") { call.respondText("ok") }
}
block(support)
}
}
@Test
fun `no Authorization header returns 401`() =
testApplication {
val (_, mod) = configure { }
application(mod)
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val response = client.get("/protected")
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@@ -61,8 +56,8 @@ class AuthJwtTest {
@Test
fun `expired token returns 401`() =
testApplication {
val (support, mod) = configure { }
application(mod)
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val token = support.mint(expiresInSeconds = -60)
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
@@ -72,8 +67,8 @@ class AuthJwtTest {
@Test
fun `wrong issuer returns 401`() =
testApplication {
val (support, mod) = configure { }
application(mod)
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val token = support.mint(iss = "https://attacker.invalid/")
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
@@ -83,8 +78,8 @@ class AuthJwtTest {
@Test
fun `wrong audience returns 401`() =
testApplication {
val (support, mod) = configure { }
application(mod)
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val token = support.mint(aud = "some-other-app")
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
@@ -94,8 +89,8 @@ class AuthJwtTest {
@Test
fun `blank sub returns 401`() =
testApplication {
val (support, mod) = configure { }
application(mod)
val support = JwtTestSupport()
application { installProtectedRoute(support) }
// JWT spec disallows an empty `sub` claim (auth0/java-jwt rejects ""),
// so we omit the claim entirely — the validate block must reject both
// null and blank as required by D-21.
@@ -108,8 +103,8 @@ class AuthJwtTest {
@Test
fun `valid RS256 token returns 200 from a protected test route`() =
testApplication {
val (support, mod) = configure { }
application(mod)
val support = JwtTestSupport()
application { installProtectedRoute(support) }
val token = support.mint()
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }

View File

@@ -36,17 +36,15 @@ internal class JwtTestSupport(
val provider: JwkProvider =
JwkProvider {
// RS256 = RSA + SHA-256; jwks-rsa Jwk needs n + e modulus/exponent (base64url).
Jwk(
keyId,
"RSA",
"RS256",
"sig",
null,
null,
null,
null,
// Use the documented Jwk.fromValues factory — the 9-arg constructors are
// both deprecated in jwks-rsa, and `allWarningsAsErrors` forbids them.
// RS256 = RSA + SHA-256; jwks-rsa needs n + e modulus/exponent (base64url).
Jwk.fromValues(
mapOf(
"kid" to keyId,
"kty" to "RSA",
"alg" to "RS256",
"use" to "sig",
"n" to publicKey.modulus.toBase64UrlNoPad(),
"e" to publicKey.publicExponent.toBase64UrlNoPad(),
),