feat(02-02): wire AuthConfig, JWT verifier, and CallLogging redaction
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
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 = ${?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.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,17 +28,13 @@ 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 {
|
||||
private fun Application.installProtectedRoute(support: JwtTestSupport) {
|
||||
install(ContentNegotiation) { json() }
|
||||
configureAuthentication(authConfigFor(support), support.provider)
|
||||
routing {
|
||||
@@ -45,15 +42,13 @@ class AuthJwtTest {
|
||||
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") }
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user