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