1

Hello I'm struggling with mocking a JWT token. I'm using JDK 18 and Spring Boot 3 and I'm using Keycloak as openid server to deliver the token to the front and it's send as Bearer token to the backend to do authenticated request.

I also use OpenApi to generate my API code but to simplify I've made a test without it.

Here's my pom dependencies.

<dependencies>
    
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-starter-oidc</artifactId>
        <version>7.0.0</version>
    </dependency>
        <dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-starter-oidc-test</artifactId>
        <version>7.0.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
        <version>${spring-cloud.version}</version>
    </dependency>

    
</dependencies>

main application.properties

server.servlet.context-path=/api
server.port=8080

spring.datasource.url = jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
spring.jpa.hibernate.ddl-auto=create-drop
#spring.jpa.hibernate.ddl-auto=update

spring.data.rest.detection-strategy=annotated

spring.jpa.properties.hibernate.hbm2ddl.auto=create-drop
# Custom H2 Console URL
spring.h2.console.path=/h2
spring.sql.init.mode=embedded
spring.sql.init.platform=h2
    
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.operationsSorter=method
springdoc.api-docs.path=/api-docs

application.initdb=true
origins: http://localhost:8080
issuer: http://localhost:8888/realms/apptest

com.c4-soft.springaddons.oidc.ops[0].iss=${issuer}
com.c4-soft.springaddons.oidc.ops[0].username-claim=preferred_username
com.c4-soft.springaddons.oidc.ops[0].authorities[0].path=$.realm_access.roles
com.c4-soft.springaddons.oidc.ops[0].authorities[0].prefix=ROLE_
com.c4-soft.springaddons.oidc.ops[0].authorities[1].path=$.resource_access.*.roles
com.c4-soft.springaddons.oidc.ops[0].authorities[1].prefix=ROLE_CLIENT_
com.c4-soft.springaddons.oidc.resourceserver.cors[0].path=/**
com.c4-soft.springaddons.oidc.resourceserver.cors[0].allowed-origin-patterns=${origins}

the application.properties in test is basically the same except com.c4-soft.springaddons.oidc

TestController

@RestController
@RequestMapping("test")
public class TestConroller {
    
    @GetMapping("/")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<String> test() {
        return ResponseEntity.ok("ok");
        
    }

}


@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockJwtUserSecurityContextFactory.class, setupBefore = TestExecutionEvent.TEST_EXECUTION)
public @interface WithMockJwtUser {
    long value() default 1L;

    String username() default "test";
    
    String email() default "test@test.fr";

    String[] roles() default {"ROLE_USER"};
}

WithMockJwtUserSecurityContextFactory

public class WithMockJwtUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockJwtUser> {
@Override
public SecurityContext createSecurityContext(WithMockJwtUser customUser) {
    Instant issuedAt = Instant.now();
    Instant expireAt = issuedAt.plus(1, ChronoUnit.HOURS);
    Jwt jwt = Jwt.withTokenValue("token")
             .header("alg", "none")
             .header("typ", "JWT")    
             .issuedAt(issuedAt)
             .expiresAt(expireAt)
   
             .claim("sub",  customUser.username())
             .claim("email_verified",false)
             .claim("iss", "http://localhost:8888/realms/apptest")
             .claim("typ","Bearer")
             .claim("preferred_username", customUser.username())
             .claim("given_name", "Test")
             .claim("sid", "98aeed08-cfd3-4c59-96d6-9a2a7ec658d2")
             .claim("session_state", "98aeed08-cfd3-4c59-96d6-9a2a7ec658d2")
             .claim("acr", 1)
             .claim("azp", "login-app")
             .claim("scope", "profile email")
             .claim("exp", expireAt)
             .claim("iat", issuedAt)
             .claim("jti", "ad071b05-68af-4d05-a58e-e71277511c8f")

             .claim("name", "Test test")
             .claim("family_name", "test")
             .claim("email", customUser.email())
             .claim("realm_access=", Map.of("roles",new String[]{"USER"}))

             .build();

     List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList(customUser.roles());
     JwtAuthenticationToken token = new JwtAuthenticationToken(jwt, authorities);
     SecurityContext context = SecurityContextHolder.createEmptyContext();
     token.setDetails(new WebAuthenticationDetails("127.0.0.1", null));
     context.setAuthentication(token);
     
     return context;

}
}

The test

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = TestApiApplication.class)
public class UserControllerIntTest {
    @LocalServerPort
    private int port;
    
    @Autowired
    private TestRestTemplate restTemplate;
@Test
//@WithMockUser("user")
@WithMockJwtUser(username="user1")
public void testTest() throws Exception {
    //given
    
    // when
    ResponseEntity<String> resp = restTemplate
            .getForEntity("http://localhost:"+port+ "/api/test", String.class);
    
    assertEquals(HttpStatus.OK, resp.getStatusCode());
    /*mockMvc.perform(get(baseUrl+"/curr"))
                        .andDo(null)
                        .andExpect(status().isOk());*/


}

When I run the test code we pass throw createSecurityContext and seems to return a valid authentication at the end. But I also noticed that log which may erase my mock. 2023-07-29T10:11:36.713+02:00 DEBUG 2496 --- [o-auto-1-exec-1] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext

I have to notice that Even @WithMockUser doesn't work.

I've already seen this similar question but I didn't found my solution. How to mock JWT authentication in a Spring Boot Unit Test?

Do you have an Idea of what's wrong with my test config ? Thanks in advance.

ScorprocS
  • 287
  • 3
  • 14

1 Answers1

2

General considerations

First, when testing a controller, you'd better write unit-tests using @WebMvcTest (and mock autowired dependencies) rather than integration-tests using @SpringBootTest. This executes faster.

Second, do not remove com.c4-soft.springaddons.oidc.* from your test properties. It is used to configure some of the security beans which are used even in @WebMvcTest and @SpringBootTest.

Third, the spring-addons-starter-oidc-test you depend on is already providing with test annotations to setup security context. Two might be of most interest to you:

  • @WithMockAuthentication to be used in cases where mocking authorities (and optionally username or actual Authentication implementation type) is enough
  • @WithJwt when you want full control on JWT claims and use the actual authentication converter to setup the test security context.

How it works

@WithMockAuthentication builds an Authentication mock, pre-configured with what you provide as annotation arguments.

@WithJwt is a bit more advanced: it uses the JSON file or String passed as argument to build a org.springframework.security.oauth2.jwt.Jwt instance (what is built after JWT decoding and validation) and then provide it as input to the Converter<Jwt, ? extends AbstractAuthenticationToken> picked from the security configuration. This means that the actual Authentication implementation (JwtAuthenticationToken by default), as well as username, authorities and claims will be the exact same as at runtime for the same JWT payload.

When using @WithJwt, extract the claims from tokens for a few representative users and dump the content as JSON files in test resources. Using a tool like https://jwt.io and real tokens it is rather simple. You could also write the JSON yourself, starting with the sample below.

Sample usage

You'll find unit and integration tests in all the samples and tutorials in the repo hosting spring-addons-starter-oidc, like for instance in this project. Just git clone https://github.com/ch4mpy/spring-addons.git and run any test, you'll see it pass, even before you setup any authorization server (I really encourage you do do so, you'll have many samples that you can debug in your IDE and code snippets to copy).

@WebMvcTest(TestConroller.class) // Use WebFluxTest in a reactive application
@AutoConfigureAddonsWebmvcResourceServerSecurity // trigger spring-addons auto-configuration
@Import({ YourWebSecurityConfigIfAny.class }) // Import your web-security configuration (if any) or decorate with `@EnableMethodSecurity` (if using it)
class TestConrollerTest {

    @Autowired
    MockMvc api;

    @Test
    @WithAnonymousUser
    void givenRequestIsAnonymous_whenGetTest_thenUnauthorized() throws Exception {
        api.perform(get("/test/")).andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockAuthentication("ROLE_USER")
    void givenUserHasMockedAuthentication_whenGetTest_thenOk() throws Exception {
        api.perform(get("/test/")).andExpect(content().string("ok"));
    }

    @Test
    @WithJwt("standrad_user.json")
    void givenUserHasJwt_whenGetTest_thenOk() throws Exception {
        api.perform(get("/test/")).andExpect(content().string("ok"));
    }
}

With something like that in src/test/resources/standrad_user.json

{
  "preferred_username": "standrad_user",
  "scope": "profile email",
  "email": "standrad_user@machin.truc",
  "email_verified": false,
  "realm_access": {
    "roles": [
      "USER"
    ]
  }
}

For an integration-test, only the test class decoration changes:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class SampleApiIntegrationTest {
    // body is the exact same as the unit-test above
}

You will also find some samples using JUnit 5 @ParameterizedTest which is very convenient when an endpoint should behave the same for various personae (UX word for "representative users"). The test will run several times, once for each of the provided identities.

P.S.

If using Spring Boot 3.1.2 (and you should), also use spring-addons 7.0.7 because of cve-2023-34035 which changed a bit the AuthorizationManagerRequestMatcherRegistry::requestMatchers signature.

ch4mp
  • 6,622
  • 6
  • 29
  • 49