1. Overview
In this tutorial, we’ll explore options for testing Spring OAuth2 access control rules with mocked identities.
We’ll use MockMvc request post-processors, WebTestClient mutators, and test annotations, from both spring-security-test and spring-addons.
2. Why Use Spring-Addons?
In the field of OAuth2, spring-security-test only offers request post-processors and mutators that require the context of respectively a MockMvc or WebTestClient request. This can be just fine for @Controllers, but it is an issue to test access control rules defined with method security (@PreAuthorize, @PostFilter, etc.) on a @Service or @Repository.
Using annotations like @WithMockJwtAuth or @WithOidcLogin, we can mock the security context when unit testing any kind of @Component in both servlet and reactive applications. This is why we’ll use spring-addons-oauth2-test during some of our tests: it provides us with such annotations for most of Spring OAuth2 Authentication implementations.
3. What Will We Test?
The companion GitHub repository contains two resource servers sharing the following features:
- secured with a JWT decoder (rather than opaque token introspection)
- require ROLE_AUTHORIZED_PERSONNEL authority to access /secured-route and /secured-method
- return 401 if authentication is missing or invalid (expired, wrong issuer, etc.) and 403 if access is denied (missing roles)
- expose a /greet endpoint accessible to any authenticated user
- use configuration to secure /secured-route (with requestMatcher and pathMatcher for servlet and reactive app, respectively)
- use method annotation to secure /secured-method
- delegate message generation to MessageService (which we’ll mock during @Controller unit tests)
- secure a method of MessageService with @PreAuthorize
- in the @Service, extract data from the JwtAuthenticationToken in the security context
To illustrate the slight differences between servlet and reactive test APIs, one is a servlet (browse code), and the second is a reactive application (browse code).
In this article, we’ll focus on testing access control rules defined in the specs above in unit and integration tests and assert that the HTTP status of the response matches the expectations according to mocked user identities (or that an exception is thrown when unit testing other @Component than @Controller, like @Service or @Repository secured with @PreAuthorize, @PostFilter and alike).
All tests pass without any authorization server, but we’ll need one to be up and running if we ever like to start the resource servers under test and query it with tools like Postman.
4. Unit Testing With Mocked Authorizations
By “unit test”, we mean a test of a single @Component in isolation of any other dependency (which we’ll mock). The tested @Component could be a @Controller in a @WebMvcTest or @WebFluxTest, as well as any other secured @Service, @Repository, etc., in a plain JUnit test.
MockMvc or WebTestClient ignores the Authorization header, and there’s no need to provide a valid access token. Of course, we could instantiate or mock any authentication implementation and manually create a security context at the beginning of each test, but this is way too tedious. Instead, we’ll use spring-security-test MockMvc request post-processors, WebTestClient mutators, or spring-addons annotations to populate the test security context with a mocked Authentication instance of our choice.
We’ll use @WithMockUser just to see that it builds a UsernamePasswordAuthenticationToken instance which is frequently an issue as OAuth2 runtime configuration puts other types of Authentication in the security context:
- JwtAuthenticationToken for resource server with a JWT decoder
- BearerTokenAuthentication for resource server with access token introspection (opaqueToken)
- OAuth2AuthenticationToken for clients with oauth2Login
- Absolutely anything if we decide to return another Authentication instance than Spring default one in a custom authentication converter. So, technically, it’s possible for an OAuth2 authentication converter to return a UsernamePasswordAuthenticationToken instance and use @WithMockUser in tests, but it’s a pretty unnatural choice, and we won’t use that here.
4.1. Test Setup
For @Controller unit tests, we should decorate test classes with @WebMvcTest for servlet apps and @WebFluxTest for reactive ones.
Spring autowires MockMvc or WebTestClient for us, and as we’re writing controller unit tests, we’ll mock MessageService.
This is what an empty @Controller unit test would look like in a servlet application:
@WebMvcTest(controllers = GreetingController.class)
class GreetingControllerTest {
@MockBean
MessageService messageService;
@Autowired
MockMvc mockMvc;
//...
}
And this is what an empty @Controller unit test would look like in a reactive application:
@WebFluxTest(controllers = GreetingController.class)
class GreetingControllerTest {
private static final AnonymousAuthenticationToken ANONYMOUS =
new AnonymousAuthenticationToken("anonymous", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
@MockBean
MessageService messageService;
@Autowired
WebTestClient webTestClient;
//...
}
Now, let’s see how to assert that HTTP status codes match the specifications we set earlier.
4.2. Unit Testing With MockMvc Post-Processors
To populate the test security context with JwtAuthenticationToken, which is the default Authentication type for resource servers with the JWT decoder, we’ll use the jwt post-processor for MockMvc requests.
First, we declare a static import of jwt request post-processor for MockMvc:
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
Then we activate and customize it by calling with(jwt()) on MockHttpServletRequestBuilder and asserting that the result status matches our specifications, depending on the user identity configured with the jwt() post-processor.
Let’s first see how to assert that MockMvc returns 401 when we mock an unauthorized request and how to mock OAuth2 authorization with jwt() request post-processor for the request to be successful:
@Test
void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception {
mockMvc.perform(get("/greet").with(anonymous()))
.andExpect(status().isUnauthorized());
}
@Test
void givenUserIsAuthenticated_whenGetGreet_thenOk() throws Exception {
var greeting = "Whatever the service returns";
when(messageService.greet()).thenReturn(greeting);
mockMvc.perform(get("/greet").with(jwt().authorities(List.of(new SimpleGrantedAuthority("admin"),
new SimpleGrantedAuthority("ROLE_AUTHORIZED_PERSONNEL")))
.jwt(jwt -> jwt.claim(StandardClaimNames.PREFERRED_USERNAME, "ch4mpy"))))
.andExpect(status().isOk())
.andExpect(content().string(greeting));
verify(messageService, times(1)).greet();
}
Then we can check that, on an endpoint implementing Role Based Access Control, MockMvc requests actually return 401 when not authorized, 200 when configured with expected authorities, and 403 when authorized but expected authorities are missing:
@Test
void givenRequestIsAnonymous_whenGetSecuredRoute_thenUnauthorized() throws Exception {
mockMvc.perform(get("/secured-route").with(anonymous()))
.andExpect(status().isUnauthorized());
}
@Test
void givenUserIsGrantedWithRoleAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception {
var secret = "Secret!";
when(messageService.getSecret()).thenReturn(secret);
mockMvc.perform(get("/secured-route").with(jwt()
.authorities(new SimpleGrantedAuthority("ROLE_AUTHORIZED_PERSONNEL"))))
.andExpect(status().isOk())
.andExpect(content().string(secret));
}
@Test
void givenUserIsNotGrantedWithRoleAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() throws Exception {
mockMvc.perform(get("/secured-route").with(jwt().authorities(new SimpleGrantedAuthority("admin"))))
.andExpect(status().isForbidden());
}
4.3. Unit Testing With WebTestClient Mutators
In the reactive resource server, the Authentication type in the security context is the same as in the servlet one: JwtAuthenticationToken. As a consequence, we’ll use the mockJwt mutator for WebTestClient.
First, declare a static import of mockJwt() mutator for WebTestClient:
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockJwt;
Then activate it by calling mutateWith(mockJwt()) on WebTestClient and assert that the result status matches our specifications.
Let’s first see how to assert that WebTestClient returns 401 when a request is anonymous and how to mock an OAuth2 authentication with mockJwt() mutator:
@Test
void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception {
webTestClient.mutateWith(mockAuthentication(ANONYMOUS))
.get()
.uri("/greet")
.exchange()
.expectStatus()
.isUnauthorized();
}
@Test
void givenUserIsAuthenticated_whenGetGreet_thenOk() throws Exception {
var greeting = "Whatever the service returns";
when(messageService.greet()).thenReturn(Mono.just(greeting));
webTestClient.mutateWith(mockJwt().authorities(List.of(new SimpleGrantedAuthority("admin"),
new SimpleGrantedAuthority("ROLE_AUTHORIZED_PERSONNEL")))
.jwt(jwt -> jwt.claim(StandardClaimNames.PREFERRED_USERNAME, "ch4mpy")))
.get()
.uri("/greet")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo(greeting);
verify(messageService, times(1)).greet();
}
Then we can check that, on an endpoint implementing Role Based Access Control, WebTestClient requests actually return 401 when not authorized, 200 when configured with expected authorities, and 403 when authorized but expected authorities are missing:
@Test
void givenRequestIsAnonymous_whenGetSecuredRoute_thenUnauthorized() throws Exception {
webTestClient.mutateWith(mockAuthentication(ANONYMOUS))
.get()
.uri("/secured-route")
.exchange()
.expectStatus()
.isUnauthorized();
}
@Test
void givenUserIsGrantedWithRoleAuthorizedPersonnel_whenGetSecuredRoute_thenOk() throws Exception {
var secret = "Secret!";
when(messageService.getSecret()).thenReturn(Mono.just(secret));
webTestClient.mutateWith(mockJwt().authorities(new SimpleGrantedAuthority("ROLE_AUTHORIZED_PERSONNEL")))
.get()
.uri("/secured-route")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo(secret);
}
@Test
void givenUserIsNotGrantedWithRoleAuthorizedPersonnel_whenGetSecuredRoute_thenForbidden() throws Exception {
webTestClient.mutateWith(mockJwt().authorities(new SimpleGrantedAuthority("admin")))
.get()
.uri("/secured-route")
.exchange()
.expectStatus()
.isForbidden();
}
4.4. Unit Testing Controllers With Annotations from Spring-Addons
We can use test annotations in the exact same way in servlet and reactive apps.
All we need is to add a dependency on spring-addons-oauth2-test:
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-oauth2-test</artifactId>
<version>6.1.0</version>
<scope>test</scope>
</dependency>
Now, we can remove identity mocking from the test body, decorating the test method with an annotation instead:
@Test
@WithAnonymousUser
void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception {
mockMvc.perform(get("/greet"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockJwtAuth(authorities = { "admin", "ROLE_AUTHORIZED_PERSONNEL" },
claims = @OpenIdClaims(preferredUsername = "ch4mpy"))
void givenUserIsAuthenticated_whenGetGreet_thenOk() throws Exception {
var greeting = "Whatever the service returns";
when(messageService.greet()).thenReturn(greeting);
mockMvc.perform(get("/greet"))
.andExpect(status().isOk())
.andExpect(content().string(greeting));
verify(messageService, times(1)).greet();
}
The identity mocking with WebTestClient is exactly the same:
@Test
@WithAnonymousUser
void givenRequestIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception {
webTestClient.get()
.uri("/greet")
.exchange()
.expectStatus()
.isUnauthorized();
}
@Test
@WithMockJwtAuth(authorities = { "admin", "ROLE_AUTHORIZED_PERSONNEL" },
claims = @OpenIdClaims(preferredUsername = "ch4mpy"))
void givenUserIsAuthenticated_whenGetGreet_thenOk() throws Exception {
var greeting = "Whatever the service returns";
when(messageService.greet()).thenReturn(Mono.just(greeting));
webTestClient.get()
.uri("/greet")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo(greeting);
verify(messageService, times(1)).greet();
}
Annotations definitely fit very well with the BDD paradigm:
- Preconditions (Given) are in text context (annotation decorating the test)
- only tested code execution (When) and result assertions (Then) are in the test body
4.5. Unit Testing @Service or @Repository Secured Method
When testing @Controller, the choice between request MockMvc post-processors (or WebTestClient mutators) and annotations is mostly a matter of team preference, but to unit test MessageService::getSecret access control, spring-security-test is no longer an option, and we’ll need spring-addons annotations.
Here is the JUnit setup:
- activate Spring auto-wiring with @ExtendWith(SpringExtension.class)
- @Import({ MessageService.class }) and @Autowire it to get an instrumented instance
- decorate test class with @EnableMethodSecurity in servlet app or @EnableReactiveMethodSecurity in reactive one
We’ll assert that MessageService throws an exception each time the user is missing the ROLE_AUTHORIZED_PERSONNEL authority.
Here is a complete unit test of a @Service in a servlet application:
@Import({ MessageService.class })
@ExtendWith(SpringExtension.class)
@EnableMethodSecurity
class MessageServiceUnitTest {
@Autowired
MessageService messageService;
@Test
void givenSecurityContextIsNotSet_whenGreet_thenThrowsAuthenticationCredentialsNotFoundException() {
assertThrows(AuthenticationCredentialsNotFoundException.class, () -> messageService.getSecret());
}
@Test
@WithAnonymousUser
void givenUserIsAnonymous_whenGreet_thenThrowsAccessDeniedException() {
assertThrows(AccessDeniedException.class, () -> messageService.getSecret());
}
@Test
@WithMockJwtAuth(authorities = { "admin", "ROLE_AUTHORIZED_PERSONNEL" },
claims = @OpenIdClaims(preferredUsername = "ch4mpy"))
void givenSecurityContextIsPopulatedWithJwtAuthenticationToken_whenGreet_thenReturnGreetingWithPreferredUsernameAndAuthorities() {
assertEquals("Hello ch4mpy! You are granted with [admin, ROLE_AUTHORIZED_PERSONNEL].",
messageService.greet());
}
@Test
@WithMockUser(authorities = { "admin", "ROLE_AUTHORIZED_PERSONNEL" }, username = "ch4mpy")
void givenSecurityContextIsPopulatedWithUsernamePasswordAuthenticationToken_whenGreet_thenThrowsClassCastException() {
assertThrows(ClassCastException.class, () -> messageService.greet());
}
}
A unit test of a @Service in a reactive application is not much different:
@Import({ MessageService.class })
@ExtendWith(SpringExtension.class)
@EnableReactiveMethodSecurity
class MessageServiceUnitTest {
@Autowired
MessageService messageService;
@Test
void givenSecurityContextIsEmpty_whenGreet_thenThrowsAuthenticationCredentialsNotFoundException() {
assertThrows(AuthenticationCredentialsNotFoundException.class, () -> messageService.greet()
.block());
}
@Test
@WithAnonymousUser
void givenUserIsAnonymous_whenGreet_thenThrowsClassCastException() {
assertThrows(ClassCastException.class, () -> messageService.greet()
.block());
}
@Test
@WithMockJwtAuth(authorities = { "admin", "ROLE_AUTHORIZED_PERSONNEL" },
claims = @OpenIdClaims(preferredUsername = "ch4mpy"))
void givenSecurityContextIsPopulatedWithJwtAuthenticationToken_whenGreet_thenReturnGreetingWithPreferredUsernameAndAuthorities() {
assertEquals("Hello ch4mpy! You are granted with [admin, ROLE_AUTHORIZED_PERSONNEL].",
messageService.greet().block());
}
@Test
@WithMockUser(authorities = { "admin", "ROLE_AUTHORIZED_PERSONNEL" }, username = "ch4mpy")
void givenSecurityContextIsPopulatedWithUsernamePasswordAuthenticationToken_whenGreet_thenThrowsClassCastException() {
assertThrows(ClassCastException.class, () -> messageService.greet().block());
}
}
5. Integration Testing With Mocked Authorizations
We’ll write Spring Boot integration tests with @SpringBootTest so that Spring wires actual components together. To keep using mocked identities, we’ll use it with MockMvc or WebTestClient. The tests themself and options to populate the test security context with mocked identities are the same as for unit tests. Only test setup changes:
- No more components mock nor argument matcher
- We’ll use @SpringBootTest(webEnvironment = WebEnvironment.MOCK) instead of @WebMvcTest or @WebFluxTest. The MOCK environment is the best match for mocked authorizations with MockMvc or WebTestClient
- decorate explicitly test class with @AutoConfigureMockMvc or @AutoConfigureWebTestClient for MockMvc or WebTestClient injection
Here is the skeleton for a Spring Boot servlet integration test:
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
class ServletResourceServerApplicationTests {
@Autowired
MockMvc api;
// Test structure and mocked identities options are the same as seen before in unit tests
}
And this is its equivalent in a reactive application:
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureWebTestClient
class ReactiveResourceServerApplicationTests {
@Autowired
WebTestClient api;
// Test structure and mocked identities options are the same as seen before in unit tests
}
Of course, this kind of integration test saves the configuration of mocks, argument captors, etc., but it is also slower and much more fragile than unit tests. We should use it with caution, maybe with lower coverage than @WebMvcTest or @WebFluxTest, just to assert that auto-wiring and inter-component communication work.
6. To Go Further
So far, we tested resource servers secured with a JWT decoder, which have JwtAuthenticationToken instances in the security context. We only ran automated tests with mocked HTTP requests without involving any authorization server in the process.
6.1. Testing With Any Type of OAuth2 Authentication
As seen earlier, Spring OAuth2 security context can hold other types of Authentication, in which case, we should use other annotations, request post-processors or mutators in tests:
- By default, resource servers with token introspection have BearerTokenAuthentication instances in their security context, and tests should use @WithMockBearerTokenAuthentication, opaqueToken(), or mockOpaqueToken()
- Clients with oauth2Login(), usually have an OAuth2AuthenticationToken in their security context and we’d use @WithOAuth2Login, @WithOidcLogin, oauth2Login(), oidcLogin(), mockOAuth2Login() or mockOidcLogin()
- Suppose we explicitly configure a custom Authentication type with http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(…) or whatever. In that case, we’ll have to provide our own unit test tooling, which is not that complicated when using spring-addons implementations as a sample. The same Github repo also contains samples with custom Authentication and dedicated test annotation
6.2. Running the Sample Applications
The sample projects contain properties for the master realm of a Keycloak instance running at https://localhost:8443. Using any other OIDC authorization server would require no more than adapting issuer-uri property and the authorities mapper in Java config: change realmRoles2AuthoritiesConverter bean to map authorities from the private claim(s) the new authorization server puts roles into.
For more details about Keycloak setup, refer to the official getting started guides. The one for standalone zip distribution might be the easiest to start with.
To set up a local Keycloak instance with TLS using a self-signed certificate, this GitHub repo could be super useful.
The authorization server should have a minimum of:
- Two declared users, one being granted the ROLE_AUTHORIZED_PERSONNEL and not the other
- A declared client with authorization code flow enabled for tools like Postman to get access tokens on behalf of those users
7. Conclusion
In this article, we explored two options for unit and integration testing Spring OAuth2 access control rules with mocked identities in both servlet and reactive applications:
- MockMvc request post-processors and WebTestClient mutators from spring-security-test
- OAuth2 test annotations from spring-addons-oauth2-test
We also saw that we could test @Controllers with MockMvc request post-processors, WebTestClient mutators, or annotations. However, only the latter enables us to set the security context when testing other types of components.
As always, we can find this source code over on GitHub.