Add authentication
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
package dev.ulfrx.recipe
|
||||
|
||||
import dev.ulfrx.recipe.auth.PrincipalResolver
|
||||
import dev.ulfrx.recipe.auth.configureAuthentication
|
||||
import dev.ulfrx.recipe.auth.meRoute
|
||||
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.netty.EngineMain
|
||||
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
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
fun main() {
|
||||
embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module)
|
||||
.start(wait = true)
|
||||
}
|
||||
fun main(args: Array<String>): Unit = EngineMain.main(args)
|
||||
|
||||
@Serializable
|
||||
private data class Health(
|
||||
@@ -22,17 +24,28 @@ private data class Health(
|
||||
)
|
||||
|
||||
fun Application.module() {
|
||||
install(CallLogging) {
|
||||
// Method + path + status only — never headers (D-23). Ktor 3.4.1's CallLoggingConfig
|
||||
// doesn't expose a header-redaction API, 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()
|
||||
Database.connect(this)
|
||||
configureRouting(PrincipalResolver())
|
||||
}
|
||||
|
||||
fun Application.configureRouting() {
|
||||
fun Application.configureRouting(principalResolver: PrincipalResolver = PrincipalResolver()) {
|
||||
routing {
|
||||
get("/health") {
|
||||
call.respond(Health(status = "ok"))
|
||||
}
|
||||
meRoute(principalResolver)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
package dev.ulfrx.recipe
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import io.ktor.server.application.Application
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.jetbrains.exposed.sql.Database as ExposedDatabase
|
||||
|
||||
object Database {
|
||||
private val log = LoggerFactory.getLogger(Database::class.java)
|
||||
|
||||
fun migrate(app: Application) {
|
||||
val url =
|
||||
app.environment.config
|
||||
.property("database.url")
|
||||
.getString()
|
||||
val user =
|
||||
app.environment.config
|
||||
.property("database.user")
|
||||
.getString()
|
||||
val password =
|
||||
app.environment.config
|
||||
.property("database.password")
|
||||
.getString()
|
||||
|
||||
val (url, user, password) = readConfig(app)
|
||||
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
|
||||
|
||||
runCatching {
|
||||
Flyway
|
||||
.configure()
|
||||
@@ -38,4 +28,48 @@ object Database {
|
||||
throw IllegalStateException("Database unreachable or migration failed", ex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire Exposed to a Hikari pool against the same JDBC URL Flyway used.
|
||||
* Must be called AFTER [migrate] so the schema is in place before the first
|
||||
* Exposed transaction runs (Phase 2 D-26).
|
||||
*/
|
||||
fun connect(app: Application) {
|
||||
val (url, user, password) = readConfig(app)
|
||||
val ds =
|
||||
HikariDataSource(
|
||||
HikariConfig().apply {
|
||||
jdbcUrl = url
|
||||
username = user
|
||||
setPassword(password)
|
||||
maximumPoolSize = 10
|
||||
minimumIdle = 2
|
||||
poolName = "recipe-pool"
|
||||
},
|
||||
)
|
||||
ExposedDatabase.connect(ds)
|
||||
log.info("Exposed connected via Hikari pool '{}'", ds.poolName)
|
||||
}
|
||||
|
||||
private data class Conf(
|
||||
val url: String,
|
||||
val user: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
private fun readConfig(app: Application): Conf =
|
||||
Conf(
|
||||
url =
|
||||
app.environment.config
|
||||
.property("database.url")
|
||||
.getString(),
|
||||
user =
|
||||
app.environment.config
|
||||
.property("database.user")
|
||||
.getString(),
|
||||
password =
|
||||
app.environment.config
|
||||
.property("database.password")
|
||||
.getString(),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt
Normal file
52
server/src/main/kotlin/dev/ulfrx/recipe/auth/AuthPlugin.kt
Normal file
@@ -0,0 +1,52 @@
|
||||
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)
|
||||
// D-21: clock-skew tolerance pinned at 30 seconds (acceptLeeway(30 seconds)).
|
||||
// Honor the AuthConfig override if set lower for tests, but never exceed 30.
|
||||
acceptLeeway(authConfig.leewaySeconds.coerceAtMost(30L))
|
||||
}
|
||||
validate { credential ->
|
||||
val sub = credential.payload.subject
|
||||
if (sub.isNullOrBlank()) null else JWTPrincipal(credential.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt
Normal file
28
server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt
Normal file
@@ -0,0 +1,28 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||
import io.ktor.server.auth.principal
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
|
||||
/**
|
||||
* Protected `GET /api/v1/me` route per D-27.
|
||||
*
|
||||
* The `authenticate("authentik")` block must be installed by [configureAuthentication]
|
||||
* before this route is registered. JIT user provisioning happens inside [PrincipalResolver]
|
||||
* on every authenticated call so claim drift propagates without an explicit migration.
|
||||
*/
|
||||
public fun Route.meRoute(principalResolver: PrincipalResolver) {
|
||||
authenticate("authentik") {
|
||||
get("/api/v1/me") {
|
||||
val jwt =
|
||||
call.principal<JWTPrincipal>()
|
||||
?: return@get call.respond(HttpStatusCode.Unauthorized)
|
||||
val me = principalResolver.resolve(jwt)
|
||||
call.respond(me)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import dev.ulfrx.recipe.shared.dto.MeResponse
|
||||
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.jetbrains.exposed.sql.TextColumnType
|
||||
import org.jetbrains.exposed.sql.statements.StatementType
|
||||
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
|
||||
|
||||
/**
|
||||
* Resolves a Ktor [JWTPrincipal] to a persisted [MeResponse] via JIT upsert (D-25).
|
||||
*
|
||||
* One row per OIDC `sub`; email + display_name refresh on every authenticated
|
||||
* request so claim drift in Authentik propagates without an explicit migration.
|
||||
*
|
||||
* Implementation detail: Postgres `INSERT ... ON CONFLICT (sub) DO UPDATE ...
|
||||
* RETURNING *` is the atomic upsert per D-25. We issue raw SQL via Exposed's
|
||||
* `exec()` so we can use `RETURNING` (Exposed 0.55.0 DSL `upsert` returns the
|
||||
* insert count, not the row, and we need the generated id and updated_at). DAO
|
||||
* is forbidden by CLAUDE.md #5; we use `exec` (DSL) not the DAO API.
|
||||
*
|
||||
* `newSuspendedTransaction(Dispatchers.IO)` is mandatory: a plain blocking
|
||||
* Exposed transaction inside a suspend route exhausts the connection pool
|
||||
* (CLAUDE.md #6, PITFALLS.md #5/#6).
|
||||
*/
|
||||
public class PrincipalResolver {
|
||||
public suspend fun resolve(principal: JWTPrincipal): MeResponse {
|
||||
val sub =
|
||||
principal.payload.subject?.takeIf { it.isNotBlank() }
|
||||
?: error("PrincipalResolver invoked with blank sub — should be blocked by AuthPlugin.validate")
|
||||
val email =
|
||||
principal.payload
|
||||
.getClaim("email")
|
||||
?.asString()
|
||||
.orEmpty()
|
||||
val nameClaim = principal.payload.getClaim("name")?.asString()
|
||||
val preferredUsername = principal.payload.getClaim("preferred_username")?.asString()
|
||||
val displayName = nameClaim ?: preferredUsername ?: email.ifBlank { sub }
|
||||
|
||||
return newSuspendedTransaction(Dispatchers.IO) {
|
||||
var row: MeResponse? = null
|
||||
// INSERT...RETURNING returns a ResultSet, so we must mark this as a SELECT
|
||||
// statement type — Exposed's `exec` defaults to executeUpdate() for INSERT
|
||||
// statements which Postgres rejects with "A result was returned when none
|
||||
// was expected."
|
||||
exec(
|
||||
stmt =
|
||||
"""
|
||||
INSERT INTO users (sub, email, display_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (sub) DO UPDATE
|
||||
SET email = EXCLUDED.email,
|
||||
display_name = EXCLUDED.display_name,
|
||||
updated_at = now()
|
||||
RETURNING id, sub, email, display_name
|
||||
""".trimIndent(),
|
||||
args =
|
||||
listOf(
|
||||
TextColumnType() to sub,
|
||||
TextColumnType() to email,
|
||||
TextColumnType() to displayName,
|
||||
),
|
||||
explicitStatementType = StatementType.SELECT,
|
||||
) { rs ->
|
||||
if (rs.next()) {
|
||||
row =
|
||||
MeResponse(
|
||||
id = rs.getObject("id").toString(),
|
||||
sub = rs.getString("sub"),
|
||||
email = rs.getString("email"),
|
||||
displayName = rs.getString("display_name"),
|
||||
)
|
||||
}
|
||||
}
|
||||
row ?: error("Upsert RETURNING produced no row for sub=$sub")
|
||||
}
|
||||
}
|
||||
}
|
||||
21
server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt
Normal file
21
server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||
|
||||
/**
|
||||
* Exposed DSL mapping for the Phase 2 `users` migration (D-24, D-26).
|
||||
*
|
||||
* DSL only — DAO is forbidden by CLAUDE.md #5. Phase 3 will add `households`,
|
||||
* `memberships`, and `invites` tables; this table stays untouched at that point
|
||||
* because identity is anchored on the OIDC `sub`.
|
||||
*/
|
||||
public object UsersTable : Table("users") {
|
||||
public val id = uuid("id")
|
||||
public val sub = text("sub")
|
||||
public val email = text("email")
|
||||
public val displayName = text("display_name")
|
||||
public val createdAt = timestamp("created_at")
|
||||
public val updatedAt = timestamp("updated_at")
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
@@ -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.ulfrx.dev/application/o/recipe-app/"
|
||||
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 = "https://auth.ulfrx.dev/application/o/recipe-app/jwks/"
|
||||
jwksUrl = ${?OIDC_JWKS_URL}
|
||||
leewaySeconds = "30"
|
||||
}
|
||||
|
||||
14
server/src/main/resources/db/migration/V1__users.sql
Normal file
14
server/src/main/resources/db/migration/V1__users.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Phase 2 D-24: principal table for OIDC-resolved users.
|
||||
-- Identity is the OIDC `sub` claim (UNIQUE); email/display_name are mutable claims
|
||||
-- refreshed by the JIT upsert in PrincipalResolver. Phase 3 layers
|
||||
-- households/memberships/invites on top of this; do NOT add household_id here.
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sub TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX users_sub_idx ON users(sub);
|
||||
@@ -1,5 +1,8 @@
|
||||
package dev.ulfrx.recipe
|
||||
|
||||
import dev.ulfrx.recipe.auth.AuthConfig
|
||||
import dev.ulfrx.recipe.auth.JwtTestSupport
|
||||
import dev.ulfrx.recipe.auth.configureAuthentication
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpStatusCode
|
||||
@@ -15,10 +18,24 @@ class ApplicationTest {
|
||||
@Test
|
||||
fun `health endpoint returns 200 with status ok`() =
|
||||
testApplication {
|
||||
// Phase 2: configureRouting now wires `meRoute` under `authenticate("authentik")`,
|
||||
// so the Authentication plugin must be installed before routing — otherwise the
|
||||
// route DSL throws MissingApplicationPluginException at registration time.
|
||||
// The verifier doesn't need to validate any tokens for this `/health` test, but
|
||||
// the plugin must exist; reuse the test JWT support to keep the wiring honest.
|
||||
val support = JwtTestSupport()
|
||||
application {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
configureAuthentication(
|
||||
AuthConfig(
|
||||
issuer = support.issuer,
|
||||
audience = support.audience,
|
||||
jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
|
||||
),
|
||||
support.provider,
|
||||
)
|
||||
configureRouting()
|
||||
}
|
||||
val response = client.get("/health")
|
||||
|
||||
113
server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt
Normal file
113
server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt
Normal file
@@ -0,0 +1,113 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import io.ktor.client.request.get
|
||||
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
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.testing.testApplication
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Negative + positive JWT validation coverage for the `/api/v1/me` boundary
|
||||
* (AUTH-03, D-21..D-23). Tests run against a hand-mounted protected route under
|
||||
* `authenticate("authentik")` so they exercise the same Ktor verifier path as
|
||||
* production without depending on Postgres or Flyway (those land in
|
||||
* `MeRouteTest` per Plan 02-02 Task 3).
|
||||
*/
|
||||
class AuthJwtTest {
|
||||
private fun authConfigFor(support: JwtTestSupport): AuthConfig =
|
||||
AuthConfig(
|
||||
issuer = support.issuer,
|
||||
audience = support.audience,
|
||||
// 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 Application.installProtectedRoute(support: JwtTestSupport) {
|
||||
install(ContentNegotiation) { json() }
|
||||
configureAuthentication(authConfigFor(support), support.provider)
|
||||
routing {
|
||||
authenticate("authentik") {
|
||||
get("/protected") { call.respondText("ok") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no Authorization header returns 401`() =
|
||||
testApplication {
|
||||
val support = JwtTestSupport()
|
||||
application { installProtectedRoute(support) }
|
||||
val response = client.get("/protected")
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired token returns 401`() =
|
||||
testApplication {
|
||||
val support = JwtTestSupport()
|
||||
application { installProtectedRoute(support) }
|
||||
val token = support.mint(expiresInSeconds = -60)
|
||||
val response =
|
||||
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wrong issuer returns 401`() =
|
||||
testApplication {
|
||||
val support = JwtTestSupport()
|
||||
application { installProtectedRoute(support) }
|
||||
val token = support.mint(iss = "https://attacker.invalid/")
|
||||
val response =
|
||||
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wrong audience returns 401`() =
|
||||
testApplication {
|
||||
val support = JwtTestSupport()
|
||||
application { installProtectedRoute(support) }
|
||||
val token = support.mint(aud = "some-other-app")
|
||||
val response =
|
||||
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank sub returns 401`() =
|
||||
testApplication {
|
||||
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.
|
||||
val token = support.mint(sub = null)
|
||||
val response =
|
||||
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid RS256 token returns 200 from a protected test route`() =
|
||||
testApplication {
|
||||
val support = JwtTestSupport()
|
||||
application { installProtectedRoute(support) }
|
||||
val token = support.mint()
|
||||
val response =
|
||||
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import com.auth0.jwk.Jwk
|
||||
import com.auth0.jwk.JwkProvider
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.interfaces.RSAPrivateKey
|
||||
import java.security.interfaces.RSAPublicKey
|
||||
import java.util.Base64
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Test helper for the Phase 2 server JWT validation surface (D-21..D-23).
|
||||
*
|
||||
* Generates an RS256 RSA-2048 keypair in-process, exposes a [JwkProvider] backed
|
||||
* by the public key (so Ktor's `verifier(jwkProvider, issuer)` can resolve the
|
||||
* `kid` without HTTP), and mints RS256 tokens with configurable claims so tests
|
||||
* can hand-craft missing/expired/wrong-issuer/wrong-audience/blank-sub variants.
|
||||
*
|
||||
* Tests must NOT spin up a real JWKS endpoint — `JwkProviderBuilder` requires a
|
||||
* URL, and using a network port from `testApplication` is fragile. The [provider]
|
||||
* surface lets `configureAuthentication(authConfig, jwkProvider)` bypass the URL
|
||||
* fetch entirely.
|
||||
*/
|
||||
internal class JwtTestSupport(
|
||||
val issuer: String = "https://test-authentik.invalid/application/o/recipe/",
|
||||
val audience: String = "recipe-app",
|
||||
val keyId: String = "test-key-1",
|
||||
) {
|
||||
private val keyPair =
|
||||
KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.genKeyPair()
|
||||
private val publicKey = keyPair.public as RSAPublicKey
|
||||
private val privateKey = keyPair.private as RSAPrivateKey
|
||||
private val algorithm = Algorithm.RSA256(publicKey, privateKey)
|
||||
|
||||
val provider: JwkProvider =
|
||||
JwkProvider {
|
||||
// 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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a signed JWT.
|
||||
*
|
||||
* @param iss override issuer (defaults to the verifier's expected [issuer]).
|
||||
* @param aud override audience (defaults to the verifier's expected [audience]).
|
||||
* @param sub subject claim; pass `""` or `null` to test the blank-sub gate.
|
||||
* @param email optional email claim.
|
||||
* @param name optional name claim (preferred_username uses the same input).
|
||||
* @param expiresInSeconds expiry offset from now; pass a negative value for
|
||||
* the expired-token negative case (default 600s = 10 min).
|
||||
*/
|
||||
fun mint(
|
||||
iss: String = issuer,
|
||||
aud: String = audience,
|
||||
sub: String? = "auth0|test-user",
|
||||
email: String? = "test@example.invalid",
|
||||
name: String? = "Test User",
|
||||
expiresInSeconds: Long = 600,
|
||||
): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val builder =
|
||||
JWT
|
||||
.create()
|
||||
.withKeyId(keyId)
|
||||
.withIssuer(iss)
|
||||
.withAudience(aud)
|
||||
.withIssuedAt(Date(now))
|
||||
.withExpiresAt(Date(now + expiresInSeconds * 1000L))
|
||||
if (sub != null) builder.withSubject(sub)
|
||||
if (email != null) builder.withClaim("email", email)
|
||||
if (name != null) {
|
||||
builder.withClaim("name", name)
|
||||
builder.withClaim("preferred_username", name)
|
||||
}
|
||||
return builder.sign(algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.math.BigInteger.toBase64UrlNoPad(): String {
|
||||
// Strip the leading sign byte that BigInteger.toByteArray() prepends when the
|
||||
// high bit is set (jwks-rsa expects unsigned big-endian per RFC 7518).
|
||||
val raw = this.toByteArray()
|
||||
val unsigned =
|
||||
if (raw.size > 1 && raw[0] == 0.toByte()) raw.copyOfRange(1, raw.size) else raw
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(unsigned)
|
||||
}
|
||||
145
server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt
Normal file
145
server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt
Normal file
@@ -0,0 +1,145 @@
|
||||
package dev.ulfrx.recipe.auth
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import dev.ulfrx.recipe.shared.dto.MeResponse
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.testing.testApplication
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.junit.AfterClass
|
||||
import org.junit.BeforeClass
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
import org.jetbrains.exposed.sql.Database as ExposedDatabase
|
||||
|
||||
/**
|
||||
* Integration test for `GET /api/v1/me` — exercises the full
|
||||
* Auth → PrincipalResolver → Postgres path against a real database (AUTH-03,
|
||||
* AUTH-06, D-25, D-27).
|
||||
*
|
||||
* Uses Testcontainers `postgres:16` rather than ambient localhost Postgres so
|
||||
* the test is hermetic across dev machines / CI. Flyway runs once per JVM
|
||||
* process before any test executes; Exposed is connected through Hikari to
|
||||
* the container.
|
||||
*/
|
||||
class MeRouteTest {
|
||||
companion object {
|
||||
private val postgres = PostgreSQLContainer("postgres:16")
|
||||
private lateinit var dataSource: HikariDataSource
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@JvmStatic
|
||||
@BeforeClass
|
||||
fun setUpClass() {
|
||||
postgres.start()
|
||||
Flyway
|
||||
.configure()
|
||||
.dataSource(postgres.jdbcUrl, postgres.username, postgres.password)
|
||||
.locations("classpath:db/migration")
|
||||
.baselineOnMigrate(true)
|
||||
.load()
|
||||
.migrate()
|
||||
dataSource =
|
||||
HikariDataSource(
|
||||
HikariConfig().apply {
|
||||
jdbcUrl = postgres.jdbcUrl
|
||||
username = postgres.username
|
||||
setPassword(postgres.password)
|
||||
maximumPoolSize = 4
|
||||
poolName = "me-route-test-pool"
|
||||
},
|
||||
)
|
||||
ExposedDatabase.connect(dataSource)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@AfterClass
|
||||
fun tearDownClass() {
|
||||
dataSource.close()
|
||||
postgres.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun authConfigFor(support: JwtTestSupport): AuthConfig =
|
||||
AuthConfig(
|
||||
issuer = support.issuer,
|
||||
audience = support.audience,
|
||||
jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
|
||||
leewaySeconds = 30,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `valid token creates user row and returns MeResponse`() =
|
||||
testApplication {
|
||||
val support = JwtTestSupport()
|
||||
val resolver = PrincipalResolver()
|
||||
application {
|
||||
install(ContentNegotiation) { json() }
|
||||
configureAuthentication(authConfigFor(support), support.provider)
|
||||
routing { meRoute(resolver) }
|
||||
}
|
||||
val token = support.mint(sub = "auth0|user-create-123")
|
||||
|
||||
val response =
|
||||
client.get("/api/v1/me") {
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val body = json.decodeFromString(MeResponse.serializer(), response.bodyAsText())
|
||||
assertEquals("auth0|user-create-123", body.sub)
|
||||
assertEquals("test@example.invalid", body.email)
|
||||
assertEquals("Test User", body.displayName)
|
||||
assertTrue(body.id.isNotBlank(), "id should be a server-generated UUID, was: ${body.id}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `second token with same sub updates email and display name without duplicating`() =
|
||||
testApplication {
|
||||
val support = JwtTestSupport()
|
||||
val resolver = PrincipalResolver()
|
||||
application {
|
||||
install(ContentNegotiation) { json() }
|
||||
configureAuthentication(authConfigFor(support), support.provider)
|
||||
routing { meRoute(resolver) }
|
||||
}
|
||||
|
||||
val first =
|
||||
client.get("/api/v1/me") {
|
||||
header(
|
||||
HttpHeaders.Authorization,
|
||||
"Bearer ${support.mint(sub = "auth0|user-update-1", email = "old@example.invalid", name = "Old Name")}",
|
||||
)
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, first.status)
|
||||
val firstBody = json.decodeFromString(MeResponse.serializer(), first.bodyAsText())
|
||||
|
||||
val second =
|
||||
client.get("/api/v1/me") {
|
||||
header(
|
||||
HttpHeaders.Authorization,
|
||||
"Bearer ${support.mint(sub = "auth0|user-update-1", email = "new@example.invalid", name = "New Name")}",
|
||||
)
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, second.status)
|
||||
val secondBody = json.decodeFromString(MeResponse.serializer(), second.bodyAsText())
|
||||
|
||||
assertEquals(firstBody.id, secondBody.id) // same row
|
||||
assertEquals(firstBody.sub, secondBody.sub)
|
||||
assertEquals("new@example.invalid", secondBody.email)
|
||||
assertEquals("New Name", secondBody.displayName)
|
||||
assertNotEquals(firstBody.email, secondBody.email)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user