From 36c1b2c822aa5871fbf945f7f2f8485258e74ed1 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 28 Apr 2026 13:06:50 +0200 Subject: [PATCH] feat(02-02): wire AuthConfig, JWT verifier, and CallLogging redaction --- .../kotlin/dev/ulfrx/recipe/Application.kt | 13 +++++ .../dev/ulfrx/recipe/auth/AuthConfig.kt | 42 ++++++++++++++++ .../dev/ulfrx/recipe/auth/AuthPlugin.kt | 50 +++++++++++++++++++ server/src/main/resources/application.conf | 13 +++++ .../dev/ulfrx/recipe/auth/AuthJwtTest.kt | 45 ++++++++--------- .../dev/ulfrx/recipe/auth/JwtTestSupport.kt | 18 +++---- 6 files changed, 146 insertions(+), 35 deletions(-) create mode 100644 server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt create mode 100644 server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt b/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt index a733627..6b7a01e 100644 --- a/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt +++ b/server/src/main/kotlin/dev/ulfrx/recipe/Application.kt @@ -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() } diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt b/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt new file mode 100644 index 0000000..57d93bb --- /dev/null +++ b/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt @@ -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, + ) + } + } +} diff --git a/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt b/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt new file mode 100644 index 0000000..3043138 --- /dev/null +++ b/server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt @@ -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) + } + } + } +} diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf index c82668d..868205b 100644 --- a/server/src/main/resources/application.conf +++ b/server/src/main/resources/application.conf @@ -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" +} diff --git a/server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt b/server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt index 26bce41..2b2daae 100644 --- a/server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt +++ b/server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt @@ -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 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") } diff --git a/server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt b/server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt index 2572b22..c1d56e0 100644 --- a/server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt +++ b/server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt @@ -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(), ),