How to correctly test for JWT unauthorized?

151 views Asked by At

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.

2

There are 2 answers

0
ch4mp On

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 (an Authentication instance, which is a JwtAuthenticationToken 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.

2
Gary Archer On

I think it is a good question actually, since developers should be able to frequently test all the security conditions of their API. This should ensure technical security such as only allowing valid issuers, and also authorization conditions after a JWT is validated. These actions also ensure that the API is client focused, eg that it returns useful OAuth error responses.

INTEGRATION TESTS WITH MOCK JWT ACCESS TOKENS

With the mock technique you are referring to, the same access token contract must be used as for real tokens. This should use the same JWT header and payload values. No security code in the API should change. Instead, the API just points to a mock JSON Web Key Set (JWKS) URI.

You can then write code like this, to test both API authentication and API authorization conditions, and assert the expected error responses for clients. Each API test can quickly get a token for any user, so that the developer setup is productive.

@Test
public void CallApi_Returns401_ForInvalidIssuer() throws Throwable {

    var jwtOptions = new MockTokenOptions();
    jwtOptions.useStandardUser();
    jwtOptions.setIssuer("https://otherissuer.com");
    var accessToken = authorizationServer.issueAccessToken(jwtOptions);

    var apiOptions = new ApiRequestOptions(accessToken);
    var response = apiClient.getCompanies(apiOptions).join();
    Assertions.assertEquals(401, response.getStatusCode(), "Unexpected HTTP status");

    // TODO: read error and error_description from the www-authenticate response header
}

EXAMPLE CODE

For something to compare against, you can run these example tests of mine. They use a mock authorization server class which deals with the technical setup, and a JWT library does the lower level work. Here are the test results for my example API:

enter image description here

OTHER TEST TECHNIQUES

Of course there are many other ways to test API security, and different developers have different preferences. It is common to use unit tests for the majority of testing. Personally though, I like to also have a handful of tests that make real HTTP requests, to prove that the end-to-end infrastructure is working as expected.