119 lines
4.5 KiB
Kotlin
119 lines
4.5 KiB
Kotlin
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)
|
|
}
|
|
}
|