From 614b57c34d766684ae63575b59bea21f9c470aed Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Tue, 28 Apr 2026 13:04:04 +0200 Subject: [PATCH] test(02-02): add failing JWT validation tests for AuthPlugin --- .../dev/ulfrx/recipe/auth/AuthJwtTest.kt | 118 ++++++++++++++++++ .../dev/ulfrx/recipe/auth/JwtTestSupport.kt | 100 +++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt create mode 100644 server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt diff --git a/server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt b/server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt new file mode 100644 index 0000000..26bce41 --- /dev/null +++ b/server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt @@ -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 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) + } +} diff --git a/server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt b/server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt new file mode 100644 index 0000000..2572b22 --- /dev/null +++ b/server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt @@ -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) +}