I have implemented a controller that authenticates on the server side (Spring Boot) using a JWT that the client gets from sign in with Apple. The problem is that I don't know how to test if the algorithm, audience, issue-uri, etc. of the received JWT is correct.
I can stub the JWT and check status 200 through authentication, but conversely I cannot write a test that returns status 401 in case of an incorrect JWT. (For example, a test that confirms that the JWT is received, but returns 401 because the issue-uri is incorrect).
The environment and implementation is below.
build.gradle.kts
plugins {
id("org.springframework.boot") version "3.1.4"
id("io.spring.dependency-management") version "1.1.3"
id("org.jetbrains.kotlin.plugin.jpa") version "1.8.22"
kotlin("jvm") version "1.8.22"
kotlin("plugin.spring") version "1.8.22"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
implementation("com.auth0:java-jwt:4.4.0")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.22")
testImplementation("junit:junit:4.13.2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test:6.1.5")
testImplementation("com.ninja-squad:springmockk:4.0.2")
testImplementation("org.wiremock:wiremock:3.3.1")
}
src/main/resources/application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://appleid.apple.com/auth/keys
issuer-uri: https://appleid.apple.com
audiences: ${APPLE_CLIENT_ID}
jws-algorithms: ${JWS_ALGORITHMS}
src/test/resources/application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://expected.com/keys
issuer-uri: https://expected.com
audiences: expected audiences
jws-algorithms: RS256
SecurityServerConfig.kt
@Configuration
class OAuth2ResourceServerSecurityConfig(
private val tokenService: DefaultTokenService
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf {
it.ignoringRequestMatchers("/api/**")
}
.authorizeHttpRequests {
it.requestMatchers(
"/api/token/apple/code"
).authenticated()
it.anyRequest().permitAll()
}
.oauth2ResourceServer {
it.jwt {}
}
.authenticationManager {
val bearerToken = it as BearerTokenAuthenticationToken
val user = tokenService.parseToken(bearerToken.token)
?: throw InvalidBearerTokenException("Invalid token")
UsernamePasswordAuthenticationToken(
user,
"",
listOf(SimpleGrantedAuthority("USER"))
)
}
return http.build()
}
}
DefaultTokenService.kt
@Service
class DefaultTokenService(
private val jwtDecoder: JwtDecoder,
) : TokenService {
override fun parseToken(bearerToken: String): AppleUser? {
return try {
val jwt = jwtDecoder.decode(bearerToken)
val userId = jwt.claims["sub"] as String
val email = jwt.claims["email"] as String
DefaultAppleUser(userId, email)
} catch (e: Exception) {
null
}
}
// more implements...
TokenController.kt
@RestController
@RequestMapping("/api/token")
class TokenController(
private val tokenService: TokenService
) {
@GetMapping("/apple/code")
fun getRefreshTokenOfApple(
authentication: Authentication,
@RequestParam authorizationCode: String,
): AppleTokenResponse? {
return tokenService.getRefreshToken(authorizationCode)
}
// more implements...
}
This is the Controller test
TokenControllerTests.kt
@SpringBootTest
@AutoConfigureMockMvc
class TokenControllerTest{
@Autowired
private lateinit var mockMvc: MockMvc
@SpykBean
private lateinit var spyStubTokenService: DefaultTokenService
@Nested
inner class GetRefreshTokenOfApple {
@Test
fun `when given jwt is invalid, status is unauthorized`() {
val result = mockMvc.perform(
MockMvcRequestBuilders
.get("/api/token/apple/code")
.param("authorizationCode", "authorization code 1")
.with(csrf())
.with(jwt().jwt {
it.issuer("https://invalid.com")
it.audience(listOf("com.invalid.app"))
it.header("alg", "RS255")
})
)
result.andExpect(MockMvcResultMatchers.status().isUnauthorized)
}
@Test
fun `status is 200 and passes correct argument to token service`() {
val result = mockMvc.perform(
MockMvcRequestBuilders
.get("/api/token/apple/code")
.param("authorizationCode", "authorization code 1")
.with(jwt().jwt {
it.issuer("https://expected.com")
it.audience(listOf("com.expected.app"))
it.header("alg", "RS256")
})
)
result.andExpect(MockMvcResultMatchers.status().isOk)
verify {
spyStubTokenService.getRefreshToken(
"authorization code 1"
)
}
}
}
The first test that expects unauthorized will fail because it will return a status of 200. We want to make sure that the JWT contents are properly checked and test that the JWT authentication is done correctly.
I'm thinking it would be best to be able to test spring.security.oauth2.resourceserver.jwt
in src/test/resources/application.yml
as the expected value.
Is this something that generally does not need to be tested in the first place?
If anyone knows of a better way to do this, I would appreciate it.
【What I have already tried】
1. JwtDecoder
with @SpykBean
annotation.
@SpykBean
private late init var jwtDecoder: JwtDecoder
I found that this can always pass certification in testing, but cannot test unauthorized.
2. I wrote a test according to this article but ended up not being able to test unauthorized correctly.
MockMvc
is adapted to "unit" tests, but what you're trying to do are more of "end-to-end" tests: you want to test that real JWTs delivered by a external authorization servers are accepted or rejected by JWT decoders that you didn't write (you just configured it).With
MockMvc
, the whole process of access token parsing and decoding (or introspection) is skipped: the MockMvc request post-processors are designed to populate the test security context with the outcome of the authentication manager (anAuthentication
instance, which is aJwtAuthenticationToken
by default for a resource server with JWT decoder).What you should "unit" test regarding security with
MockMvc
are access control rules. I wrote an article for that on Baeldung.