test(02-02): add failing JWT validation tests for AuthPlugin

This commit is contained in:
2026-04-28 13:04:04 +02:00
parent fe8c0b6823
commit 614b57c34d
2 changed files with 218 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
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.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 configure(
block: io.ktor.server.application.Application.(JwtTestSupport) -> Unit,
): Pair<JwtTestSupport, io.ktor.server.application.Application.() -> Unit> {
val support = JwtTestSupport()
return support to {
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 response = client.get("/protected")
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `expired token returns 401`() =
testApplication {
val (support, mod) = configure { }
application(mod)
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, mod) = configure { }
application(mod)
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, mod) = configure { }
application(mod)
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, mod) = configure { }
application(mod)
// 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, mod) = configure { }
application(mod)
val token = support.mint()
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.OK, response.status)
}
}

View File

@@ -0,0 +1,100 @@
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 {
// RS256 = RSA + SHA-256; jwks-rsa Jwk needs n + e modulus/exponent (base64url).
Jwk(
keyId,
"RSA",
"RS256",
"sig",
null,
null,
null,
null,
mapOf(
"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)
}