Testing method security (@PreAuthorize
, @PostFilter
, etc.) requires to configure the security context. Spring-security-test
provides with MockMvc
request post-processors and WebTestClient
mutators to do so, but this requires the context of a request, which limits its usage to testing secured controllers.
To test method security on any type of @Component
(@Controller
, off course, but also @Service
and @Repository
) there are only two options: build tests security context by yourself and populate it with stubbed / mocked authentications, or use annotations to do it for you. This lib contains annotations to configure test security context with OAuth2 authentication at your hand.
@WithMockAuthentication
should be enough to test any kind of OAuth2 application with RBAC (role-based access control): it allows to easily define name
and authorities
, as well as the Authentication a principal types to mock if your application code expects something specific.
In the case where your access-control uses more than just name and authorities, you'll probably need to define claim-set details. In this case:
- on an
oauth2ResourceServer
,@WithJwt
and@WithOpaqueToken
which build the authentication instance using a JSON payload from the classpath and the authentication converter in the context (see the Important warning below). - on a client with
oauth2Login
,@WithOAuth2Login
and@WithOidcLogin
An article covering the usage of OAuth2 test annotations from this lib was published on Baeldung.
This, along with all samples and tutorials source-code (which contain a lot of unit and integration testing), should be enough to get you started.
You might also have a look at the Sample section below.
@WithJwt
and @WithMockJwtAuth
require custom authentication converter to be exposed as a @Bean (instead of inlining it with a lambda in the SecurityFilterChain
definition). The authentication factory needs this bean to build the same Authentication instance as you would get at runtime.
spring-addons-starter-oidc
exposes the authentication converter as a bean, bust most samples from the wenb using just spring-boot-starter-oauth2-resource-server
don't.
In practice, instead of inlining the authentication converter in the SecurityFilterChain
definition, use something like:
@Bean
// It is important that what implements Converter<Jwt, AbstractAuthenticationToken> is exposed as a @Bean
JwtAuthenticationConverter authenticationConverter() {
final var authenticationConverter = new JwtAuthenticationConverter();
authenticationConverter.setPrincipalClaimName(StandardClaimNames.PREFERRED_USERNAME);
authenticationConverter
.setJwtGrantedAuthoritiesConverter(
(jwt) -> Optional
.ofNullable(jwt.getClaimAsStringList("roles"))
.orElse(List.of())
.stream()
.map(SimpleGrantedAuthority::new)
.map(GrantedAuthority.class::cast)
.toList());
return authenticationConverter;
}
@Bean
// This bean can then be injected in your SecurityFilterChain as follow
SecurityFilterChain securityFilterCHain(HttpSecurity http, Converter<Jwt, AbstractAuthenticationToken> authenticationConverter) throws Exception {
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(authenticationConverter)));
...
return http.build();
}
This is important for the factory behinf the test annotation to get this authentication converter from the test context (and use it to build the Authentication
instance it puts in the test security context).
Let's consider the following secured @Service
@Service
public class SecuredService {
@PreAuthorize("hasAuthority('NICE')")
String nice() {
return "Dear %s, glad to see you!".formatted(SecurityContextHolder.getContext().getAuthentication().getName());
}
@PreAuthorize("isAuthenticated()")
String hello() {
return "Hello %s.".formatted(SecurityContextHolder.getContext().getAuthentication().getName());
}
}
Now, let's assume that you have a staging environment with a few representative users ("personas" if you are familiar with UX), for which you can get sample access tokens, and dump the claims in JSON files in test resources in (by decoding JWTs with a tool like https://jwt.io or introspecting opaque tokens). In the following, we'll consider you have a user named brice
with NICE
authority and another one named igor
without the NICE
authority. We'll also consider you have dumped sample claim-sets in src/test/resources/brice.json
and src/test/resources/igor.json
.
When testing RBAC (role-based access control), defining just authorities is frequently enough. Sometimes, defining the Authentication#name
is necessary and in a few cases, application code needs a specific Authentication
implementation. @WithMockAuthentication
was designed to meet these requirements:
@SpringBootTest(classes = { SecurityConfig.class, MessageService.class })
class MessageServiceTests {
@Autowired
private SecuredService securedService;
@Test
@WithMockAuthentication("BAD_BOY")
void givenUserIsNotGrantedWithNice_whenCallNice_thenThrows() {
assertThrows(Exception.class, () -> securedService.nice());
}
@Test
@WithMockAuthentication(name = "brice", authorities = "NICE")
void givenUserIsNice_whenCallNice_thenReturnsGreeting() {
assertThat(securedService.nice()).isEqualTo("Dear brice, glad to see you!");
}
@ParameterizedTest
@AuthenticationSource(
@WithMockAuthentication(name = "brice", authorities = "NICE"),
@WithMockAuthentication(name = "ch4mp", authorities = { "VERY_NICE", "AUTHOR" }))
void givenUserIsAuthenticated_whenCallHello_thenReturnsGreeting(@ParameterizedAuthentication Authentication auth) {
assertThat(securedService.hello()).isEqualTo("Hello %s.".formatted(auth.getName()));
}
}
@WithJwt
and @WithOpaqueToken
enable to load those claim-sets and turn it into Authentication
instances.
For the Authentication to be built as at runtime (type, authorities, name, claims, etc.), @WithJwt
uses your Converter<Jwt, ? extends AbstractAuthenticationToken>
and @WithOpaqueToken
the OpaqueTokenAuthenticationConverter
(or the reactive counterparts in reactive apps).
For the wiring to happen correctly, you need to import AuthenticationFactoriesTestConf
(it is already imported when using one of @AddonsWebmvcComponentTest
, AutoConfigureAddonsWebmvcClientSecurity
, AutoConfigureAddonsWebmvcResourceServerSecurity
or their reactive counterparts).
@Import(AuthenticationFactoriesTestConf.class) // when using spring-addons-oauth2-test but not spring-addons-starter-oidc
@SpringBootTest(classes = { SecurityConfig.class, MessageService.class })
class MessageServiceTests {
@Autowired
private SecuredService securedService;
@Autowired
WithJwt.AuthenticationFactory authFactory;
@Test
@WithJwt("igor.json")
void givenUserIsIgor_whenCallNice_thenThrows() {
assertThrows(Exception.class, () -> securedService.nice());
}
@Test
@WithJwt("brice.json")
void givenUserIsBrice_whenCallNice_thenReturnsGreeting() {
assertThat(securedService.nice()).isEqualTo("Dear brice, glad to see you!");
}
@ParameterizedTest
@MethodSource("identities")
void givenUserIsAuthenticated_whenCallHello_thenReturnsGreeting(@ParameterizedAuthentication Authentication auth) {
assertThat(securedService.hello()).isEqualTo("Hello %s.".formatted(auth.getName()));
}
Stream<AbstractAuthenticationToken> identities() {
return authFactory.authenticationsFrom("brice.json", "igor.json");
}
}
There are we few things worth noting above:
- we are testing a
@Service
having methods decorated with@PreAuthorize
, withoutMockMvc
orWebTestClient
(and their request post-processors or mutators) - authorities and username will be coherent with claims during tests (it is not necessarily the case when we declare the 3 separately as done with MockMvc request post-processors and WebTestClient mutators).
WithJwt.AuthenticationFactory
uses the JWT authorities converter found in security configuration. As a consequence,username
andauthorities
are resolved from claims, just as it is at runtime. - the claims are loaded from a JSON files in the test classpath
- we are using JUnit 5
@ParameterizedTest
: the test will run once for each of the authentication in the stream provided by theidentities
method - annotations fit so well with BDD (given-when-then): the test pre-conditions (given) are decorating the test instead of cluttering its content like MockMvc request post-processors and WebTestClient mutators do
- annotations can be very brief and expressive
Important warning for those using @WithJwt
(and since 7.3.0
, @WithMockJwtAuth
) but not spring-addons-starter-oidc
: you should expose your JWT converter as a bean. In practice, instead of inlining the authentication converter in the SecurityFilterChain
definition, use something like:
@Bean
// It is important that what implements Converter<Jwt, AbstractAuthenticationToken> is exposed as a @Bean
JwtAuthenticationConverter authenticationConverter() {
final var authenticationConverter = new JwtAuthenticationConverter();
authenticationConverter.setPrincipalClaimName(StandardClaimNames.PREFERRED_USERNAME);
authenticationConverter
.setJwtGrantedAuthoritiesConverter(
(jwt) -> Optional
.ofNullable(jwt.getClaimAsStringList("roles"))
.orElse(List.of())
.stream()
.map(SimpleGrantedAuthority::new)
.map(GrantedAuthority.class::cast)
.toList());
return authenticationConverter;
}
@Bean
// This bean can then be injected in your SecurityFilterChain as follow
SecurityFilterChain securityFilterCHain(HttpSecurity http, Converter<Jwt, AbstractAuthenticationToken> authenticationConverter) throws Exception {
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(authenticationConverter)));
...
return http.build();
}
This is important for the test annotation to get this authentication converter from the test context (and use it to build the Authentication
instance it puts in the test security context).