Spring Boot 1.5 introduced test slices like @WebMvcTest
. Using these test slices and manually load the OAuth2AutoConfiguration
gives your tests less boilerplate and they'll run faster than the proposed @SpringBootTest
based solutions. If you also import your production security configuration, you can test that the configured filter chains is working for your web services.
Here's the setup along with some additional classes that you'll probably find beneficial:
Controller:
@RestController
@RequestMapping(BookingController.API_URL)
public class BookingController {
public static final String API_URL = "/v1/booking";
@Autowired
private BookingRepository bookingRepository;
@PreAuthorize("#oauth2.hasScope('myapi:write')")
@PatchMapping(consumes = APPLICATION_JSON_UTF8_VALUE, produces = APPLICATION_JSON_UTF8_VALUE)
public Booking patchBooking(OAuth2Authentication authentication, @RequestBody @Valid Booking booking) {
String subjectId = MyOAuth2Helper.subjectId(authentication);
booking.setSubjectId(subjectId);
return bookingRepository.save(booking);
}
}
Test:
@RunWith(SpringRunner.class)
@AutoConfigureJsonTesters
@WebMvcTest
@Import(DefaultTestConfiguration.class)
public class BookingControllerTest {
@Autowired
private MockMvc mvc;
@Autowired
private JacksonTester<Booking> json;
@MockBean
private BookingRepository bookingRepository;
@MockBean
public ResourceServerTokenServices resourceServerTokenServices;
@Before
public void setUp() throws Exception {
// Stub the remote call that loads the authentication object
when(resourceServerTokenServices.loadAuthentication(anyString())).thenAnswer(invocation -> SecurityContextHolder.getContext().getAuthentication());
}
@Test
@WithOAuthSubject(scopes = {"myapi:read", "myapi:write"})
public void mustHaveValidBookingForPatch() throws Exception {
mvc.perform(patch(API_URL)
.header(AUTHORIZATION, "Bearer foo")
.content(json.write(new Booking("myguid", "aes")).getJson())
.contentType(MediaType.APPLICATION_JSON_UTF8)
).andExpect(status().is2xxSuccessful());
}
}
DefaultTestConfiguration:
@TestConfiguration
@Import({MySecurityConfig.class, OAuth2AutoConfiguration.class})
public class DefaultTestConfiguration {
}
MySecurityConfig (this is for production):
@Configuration
@EnableOAuth2Client
@EnableResourceServer
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/v1/**").authenticated();
}
}
Custom annotation for injecting scopes from tests:
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithOAuthSubjectSecurityContextFactory.class)
public @interface WithOAuthSubject {
String[] scopes() default {"myapi:write", "myapi:read"};
String subjectId() default "a1de7cc9-1b3a-4ecd-96fa-dab6059ccf6f";
}
Factory class for handling the custom annotation:
public class WithOAuthSubjectSecurityContextFactory implements WithSecurityContextFactory<WithOAuthSubject> {
private DefaultAccessTokenConverter defaultAccessTokenConverter = new DefaultAccessTokenConverter();
@Override
public SecurityContext createSecurityContext(WithOAuthSubject withOAuthSubject) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
// Copy of response from https://myidentityserver.com/identity/connect/accesstokenvalidation
Map<String, ?> remoteToken = ImmutableMap.<String, Object>builder()
.put("iss", "https://myfakeidentity.example.com/identity")
.put("aud", "oauth2-resource")
.put("exp", OffsetDateTime.now().plusDays(1L).toEpochSecond() + "")
.put("nbf", OffsetDateTime.now().plusDays(1L).toEpochSecond() + "")
.put("client_id", "my-client-id")
.put("scope", Arrays.asList(withOAuthSubject.scopes()))
.put("sub", withOAuthSubject.subjectId())
.put("auth_time", OffsetDateTime.now().toEpochSecond() + "")
.put("idp", "idsrv")
.put("amr", "password")
.build();
OAuth2Authentication authentication = defaultAccessTokenConverter.extractAuthentication(remoteToken);
context.setAuthentication(authentication);
return context;
}
}
I use a copy of the response from our identity server for creating a realistic OAuth2Authentication
. You can probably just copy my code. If you want to repeat the process for your identity server, place a breakpoint in org.springframework.security.oauth2.provider.token.RemoteTokenServices#loadAuthentication
or org.springframework.boot.autoconfigure.security.oauth2.resource.UserInfoTokenServices#extractAuthentication
, depending on whether you have configured a custom ResourceServerTokenServices
or not.