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 package dev.ulfrx.recipe
import dev.ulfrx.recipe.auth.configureAuthentication
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty import io.ktor.server.netty.Netty
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation 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.response.respond
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.routing import io.ktor.server.routing.routing
@@ -22,9 +26,18 @@ private data class Health(
) )
fun Application.module() { 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) { install(ContentNegotiation) {
json() json()
} }
configureAuthentication()
Database.migrate(this) Database.migrate(this)
configureRouting() 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 = "recipe"
password = ${?DATABASE_PASSWORD} 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.HttpHeaders
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.auth.authenticate import io.ktor.server.auth.authenticate
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
@@ -27,17 +28,13 @@ class AuthJwtTest {
AuthConfig( AuthConfig(
issuer = support.issuer, issuer = support.issuer,
audience = support.audience, 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. // just needs to be a syntactically-valid URL so AuthConfig invariants hold.
jwksUrl = "https://test-authentik.invalid/application/o/jwks/", jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
leewaySeconds = 30, leewaySeconds = 30,
) )
private fun configure( private fun Application.installProtectedRoute(support: JwtTestSupport) {
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() } install(ContentNegotiation) { json() }
configureAuthentication(authConfigFor(support), support.provider) configureAuthentication(authConfigFor(support), support.provider)
routing { routing {
@@ -45,15 +42,13 @@ class AuthJwtTest {
get("/protected") { call.respondText("ok") } get("/protected") { call.respondText("ok") }
} }
} }
block(support)
}
} }
@Test @Test
fun `no Authorization header returns 401`() = fun `no Authorization header returns 401`() =
testApplication { testApplication {
val (_, mod) = configure { } val support = JwtTestSupport()
application(mod) application { installProtectedRoute(support) }
val response = client.get("/protected") val response = client.get("/protected")
assertEquals(HttpStatusCode.Unauthorized, response.status) assertEquals(HttpStatusCode.Unauthorized, response.status)
} }
@@ -61,8 +56,8 @@ class AuthJwtTest {
@Test @Test
fun `expired token returns 401`() = fun `expired token returns 401`() =
testApplication { testApplication {
val (support, mod) = configure { } val support = JwtTestSupport()
application(mod) application { installProtectedRoute(support) }
val token = support.mint(expiresInSeconds = -60) val token = support.mint(expiresInSeconds = -60)
val response = val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") } client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
@@ -72,8 +67,8 @@ class AuthJwtTest {
@Test @Test
fun `wrong issuer returns 401`() = fun `wrong issuer returns 401`() =
testApplication { testApplication {
val (support, mod) = configure { } val support = JwtTestSupport()
application(mod) application { installProtectedRoute(support) }
val token = support.mint(iss = "https://attacker.invalid/") val token = support.mint(iss = "https://attacker.invalid/")
val response = val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") } client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
@@ -83,8 +78,8 @@ class AuthJwtTest {
@Test @Test
fun `wrong audience returns 401`() = fun `wrong audience returns 401`() =
testApplication { testApplication {
val (support, mod) = configure { } val support = JwtTestSupport()
application(mod) application { installProtectedRoute(support) }
val token = support.mint(aud = "some-other-app") val token = support.mint(aud = "some-other-app")
val response = val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") } client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
@@ -94,8 +89,8 @@ class AuthJwtTest {
@Test @Test
fun `blank sub returns 401`() = fun `blank sub returns 401`() =
testApplication { testApplication {
val (support, mod) = configure { } val support = JwtTestSupport()
application(mod) application { installProtectedRoute(support) }
// JWT spec disallows an empty `sub` claim (auth0/java-jwt rejects ""), // JWT spec disallows an empty `sub` claim (auth0/java-jwt rejects ""),
// so we omit the claim entirely — the validate block must reject both // so we omit the claim entirely — the validate block must reject both
// null and blank as required by D-21. // null and blank as required by D-21.
@@ -108,8 +103,8 @@ class AuthJwtTest {
@Test @Test
fun `valid RS256 token returns 200 from a protected test route`() = fun `valid RS256 token returns 200 from a protected test route`() =
testApplication { testApplication {
val (support, mod) = configure { } val support = JwtTestSupport()
application(mod) application { installProtectedRoute(support) }
val token = support.mint() val token = support.mint()
val response = val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") } client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }

View File

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