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 "[email protected]";
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.
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.@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.
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": "[email protected]",
"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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With