feat(02-02): wire AuthConfig, JWT verifier, and CallLogging redaction
This commit is contained in:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
42
server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt
Normal file
42
server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthConfig.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt
Normal file
50
server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,33 +28,27 @@ 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,
|
install(ContentNegotiation) { json() }
|
||||||
): Pair<JwtTestSupport, io.ktor.server.application.Application.() -> Unit> {
|
configureAuthentication(authConfigFor(support), support.provider)
|
||||||
val support = JwtTestSupport()
|
routing {
|
||||||
return support to {
|
authenticate("authentik") {
|
||||||
install(ContentNegotiation) { json() }
|
get("/protected") { call.respondText("ok") }
|
||||||
configureAuthentication(authConfigFor(support), support.provider)
|
|
||||||
routing {
|
|
||||||
authenticate("authentik") {
|
|
||||||
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") }
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user