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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user