[Edit on May 2019]
The Solution below is specific to spring-security-oauth2 which is now deprecated.
I wrote a lib to achieve the same goal with Spring5, some of which is contributed to spring-security-test 5.2. They chose to integrate the JWT flow API only, so if you need to test a service (requires the use of an annotation) or use opaque tokens introspection, you might need to browse my repo a bit...
[Edit on July 2019]
I now publish my "spring-addons" libs for Spring 5 to maven-central, which greatly improve usability.
Source and READMEs still on github.
[solution for spring-security-oauth2]
The solution I iterated to is combining a dummy "Authorization" header in requests with a mocked token service intercepting it (after quite a few tries if you look at edits stack).
I provide with complete helpers source in a lib on Github and you can find sample OAuth2 controller test there.
To make it short: no Authorization header -> ResourceServerTokenServices is not triggered -> SecurityContext will be anonymous in OAuth stack (whatever you try to set it to with @WithMockUser or alike).
So two cases here:
- you're writing integration tests, provide valid tokens and let real token service do it's job and provide authentication contained in this token
- you're writing unit tests, my case, and mock token service so that it returns mocked authentication
A similar approach, I understood after pulling my hair for a few days and building this from ground up, has already been described here. I just went further in mocked Oauth2Authentication configuration and tooling for @WebMvcTest
s.
Sample usage
As this post is long, exposing a solution involving quite some code, lets get started with the result so that you can decide if it's worth reading ;)
@WebMvcTest(MyController.class) // Controller to unit-test
@Import(WebSecurityConfig.class) // your class extending WebSecurityConfigurerAdapter
public class MyControllerTest extends OAuth2ControllerTest {
@Test
public void testWithUnauthenticatedClient() throws Exception {
api.post(payload, "/endpoint")
.andExpect(...);
}
@Test
@WithMockOAuth2Client
public void testWithDefaultClient() throws Exception {
api.get("/endpoint")
.andExpect(...);
}
@Test
@WithMockOAuth2User
public void testWithDefaultClientOnBehalfDefaultUser() throws Exception {
MockHttpServletRequestBuilder req = api.postRequestBuilder(null, "/uaa/refresh")
.header("refresh_token", JWT_REFRESH_TOKEN);
api.perform(req)
.andExpect(status().isOk())
.andExpect(...)
}
@Test
@WithMockOAuth2User(
client = @WithMockOAuth2Client(
clientId = "custom-client",
scope = {"custom-scope", "other-scope"},
authorities = {"custom-authority", "ROLE_CUSTOM_CLIENT"}),
user = @WithMockUser(
username = "custom-username",
authorities = {"custom-user-authority"}))
public void testWithCustomClientOnBehalfCustomUser() throws Exception {
api.get(MediaType.APPLICATION_ATOM_XML, "/endpoint")
.andExpect(status().isOk())
.andExpect(xpath(...));
}
}
Funky, isn't it ?
P.S. api
is an instance of MockMvcHelper
, a wrapper of my own for MockMvc
, provided at the end of this post.
@WithMockOAuth2Client to simulate client only authentication (no end-user involved)
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockOAuth2Client.WithMockOAuth2ClientSecurityContextFactory.class)
public @interface WithMockOAuth2Client {
String clientId() default "web-client";
String[] scope() default {"openid"};
String[] authorities() default {};
boolean approved() default true;
class WithMockOAuth2ClientSecurityContextFactory implements WithSecurityContextFactory<WithMockOAuth2Client> {
public static OAuth2Request getOAuth2Request(final WithMockOAuth2Client annotation) {
final Set<? extends GrantedAuthority> authorities = Stream.of(annotation.authorities())
.map(auth -> new SimpleGrantedAuthority(auth))
.collect(Collectors.toSet());
final Set<String> scope = Stream.of(annotation.scope())
.collect(Collectors.toSet());
return new OAuth2Request(
null,
annotation.clientId(),
authorities,
annotation.approved(),
scope,
null,
null,
null,
null);
}
@Override
public SecurityContext createSecurityContext(final WithMockOAuth2Client annotation) {
final SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(new OAuth2Authentication(getOAuth2Request(annotation), null));
SecurityContextHolder.setContext(ctx);
return ctx;
}
}
}
@WithMockOAuth2User to simulate client authenticating on behalf of an end-user
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockOAuth2User.WithMockOAuth2UserSecurityContextFactory.class)
public @interface WithMockOAuth2User {
WithMockOAuth2Client client() default @WithMockOAuth2Client();
WithMockUser user() default @WithMockUser();
class WithMockOAuth2UserSecurityContextFactory implements WithSecurityContextFactory<WithMockOAuth2User> {
/**
* Sadly, #WithMockUserSecurityContextFactory is not public,
* so re-implement mock user authentication creation
*
* @param user
* @return an Authentication with provided user details
*/
public static UsernamePasswordAuthenticationToken getUserAuthentication(final WithMockUser user) {
final String principal = user.username().isEmpty() ? user.value() : user.username();
final Stream<String> grants = user.authorities().length == 0 ?
Stream.of(user.roles()).map(r -> "ROLE_" + r) :
Stream.of(user.authorities());
final Set<? extends GrantedAuthority> userAuthorities = grants
.map(auth -> new SimpleGrantedAuthority(auth))
.collect(Collectors.toSet());
return new UsernamePasswordAuthenticationToken(
new User(principal, user.password(), userAuthorities),
principal + ":" + user.password(),
userAuthorities);
}
@Override
public SecurityContext createSecurityContext(final WithMockOAuth2User annotation) {
final SecurityContext ctx = SecurityContextHolder.createEmptyContext();
ctx.setAuthentication(new OAuth2Authentication(
WithMockOAuth2Client.WithMockOAuth2ClientSecurityContextFactory.getOAuth2Request(annotation.client()),
getUserAuthentication(annotation.user())));
SecurityContextHolder.setContext(ctx);
return ctx;
}
}
}
OAuth2MockMvcHelper helps build test requests with expected Authorization header
public class OAuth2MockMvcHelper extends MockMvcHelper {
public static final String VALID_TEST_TOKEN_VALUE = "test.fake.jwt";
public OAuth2MockMvcHelper(
final MockMvc mockMvc,
final ObjectFactory<HttpMessageConverters> messageConverters,
final MediaType defaultMediaType) {
super(mockMvc, messageConverters, defaultMediaType);
}
/**
* Adds OAuth2 support: adds an Authorisation header to all request builders
* if there is an OAuth2Authentication in test security context.
*
* /! Make sure your token services recognize this dummy "VALID_TEST_TOKEN_VALUE" token as valid during your tests /!
*
* @param contentType should be not-null when issuing request with body (POST, PUT, PATCH), null otherwise
* @param accept should be not-null when issuing response with body (GET, POST, OPTION), null otherwise
* @param method
* @param urlTemplate
* @param uriVars
* @return a request builder with minimal info you can tweak further (add headers, cookies, etc.)
*/
@Override
public MockHttpServletRequestBuilder requestBuilder(
Optional<MediaType> contentType,
Optional<MediaType> accept,
HttpMethod method,
String urlTemplate,
Object... uriVars) {
final MockHttpServletRequestBuilder builder = super.requestBuilder(contentType, accept, method, urlTemplate, uriVars);
if (SecurityContextHolder.getContext().getAuthentication() instanceof OAuth2Authentication) {
builder.header("Authorization", "Bearer " + VALID_TEST_TOKEN_VALUE);
}
return builder;
}
}
OAuth2ControllerTest a parent for controllers unit-tests
@RunWith(SpringRunner.class)
@Import(OAuth2MockMvcConfig.class)
public class OAuth2ControllerTest {
@MockBean
private ResourceServerTokenServices tokenService;
@Autowired
protected OAuth2MockMvcHelper api;
@Autowired
protected SerializationHelper conv;
@Before
public void setUpTokenService() {
when(tokenService.loadAuthentication(api.VALID_TEST_TOKEN_VALUE))
.thenAnswer(invocation -> SecurityContextHolder.getContext().getAuthentication());
}
}
@TestConfiguration
class OAuth2MockMvcConfig {
@Bean
public SerializationHelper serializationHelper(ObjectFactory<HttpMessageConverters> messageConverters) {
return new SerializationHelper(messageConverters);
}
@Bean
public OAuth2MockMvcHelper mockMvcHelper(
MockMvc mockMvc,
ObjectFactory<HttpMessageConverters> messageConverters,
@Value("${controllers.default-media-type:application/json;charset=UTF-8}") MediaType defaultMediaType) {
return new OAuth2MockMvc