test(02-02): add failing JWT validation tests for AuthPlugin
This commit is contained in:
118
server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt
Normal file
118
server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
100
server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt
Normal file
100
server/src/test/kotlin/dev/ulfrx/recipe/auth/JwtTestSupport.kt
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user