I am using Spring Boot 2.2.1 with spring-security-oauth2-resource-server:5.2.0.RELEASE
. I want to write an integration test to test the security is ok.
I have this WebSecurityConfigurerAdapter
defined in my application:
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
private final OAuth2ResourceServerProperties properties;
private final SecuritySettings securitySettings;
public WebSecurityConfiguration(OAuth2ResourceServerProperties properties, SecuritySettings securitySettings) {
this.properties = properties;
this.securitySettings = securitySettings;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/**")
.authenticated()
.and()
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
}
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder result = NimbusJwtDecoder.withJwkSetUri(properties.getJwt().getJwkSetUri())
.build();
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefault(),
new AudienceValidator(securitySettings.getApplicationId()));
result.setJwtValidator(validator);
return result;
}
private static class AudienceValidator implements OAuth2TokenValidator<Jwt> {
private final String applicationId;
public AudienceValidator(String applicationId) {
this.applicationId = applicationId;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
if (token.getAudience().contains(applicationId)) {
return OAuth2TokenValidatorResult.success();
} else {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "The audience is not as expected, got " + token.getAudience(),
null));
}
}
}
}
It has a custom validator to check the audience (aud
) claim in the token.
I currently have this test, which works, but it does not check the audience claim at all:
@WebMvcTest(UserController.class)
@EnableConfigurationProperties({SecuritySettings.class, OAuth2ResourceServerProperties.class})
@ActiveProfiles("controller-test")
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void testOwnUserDetails() throws Exception {
mockMvc.perform(get("/api/users/me")
.with(jwt(createJwtToken())))
.andExpect(status().isOk())
.andExpect(jsonPath("userId").value("AZURE-ID-OF-USER"))
.andExpect(jsonPath("name").value("John Doe"));
}
@Test
void testOwnUserDetailsWhenNotLoggedOn() throws Exception {
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized());
}
@NotNull
private Jwt createJwtToken() {
String userId = "AZURE-ID-OF-USER";
String userName = "John Doe";
String applicationId = "AZURE-APP-ID";
return Jwt.withTokenValue("fake-token")
.header("typ", "JWT")
.header("alg", "none")
.claim("iss",
"https://b2ctestorg.b2clogin.com/80880907-bc3a-469a-82d1-b88ffad655df/v2.0/")
.claim("idp", "LocalAccount")
.claim("oid", userId)
.claim("scope", "user_impersonation")
.claim("name", userName)
.claim("azp", applicationId)
.claim("ver", "1.0")
.subject(userId)
.audience(Set.of(applicationId))
.build();
}
}
I also have a properties file for the controller-test
profile that contains the application id and the jwt-set-uri:
security-settings.application-id=FAKE_ID
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://b2ctestorg.b2clogin.com/b2ctestorg.onmicrosoft.com/discovery/v2.0/keys?p=b2c_1_ropc_flow
Maybe the JwtDecoder is not used because the Jwt is created manually? How could I make sure the JwtDecoder is called in the test?
To elaborate on the answer from Eleftheria Stein-Kousathana, this is what I changed to make it possible:
1) Create a JwtDecoderFactoryBean
class to be able to unit test the JwtDecoder
and the configured validators:
@Component
public class JwtDecoderFactoryBean implements FactoryBean<JwtDecoder> {
private final OAuth2ResourceServerProperties properties;
private final SecuritySettings securitySettings;
private final Clock clock;
public JwtDecoderFactoryBean(OAuth2ResourceServerProperties properties,
SecuritySettings securitySettings,
Clock clock) {
this.properties = properties;
this.securitySettings = securitySettings;
this.clock = clock;
}
@Override
public JwtDecoder getObject() {
JwtTimestampValidator timestampValidator = new JwtTimestampValidator();
timestampValidator.setClock(clock);
JwtIssuerValidator issuerValidator = new JwtIssuerValidator(securitySettings.getJwtIssuer());
JwtAudienceValidator audienceValidator = new JwtAudienceValidator(securitySettings.getJwtApplicationId());
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
timestampValidator,
issuerValidator,
audienceValidator);
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(properties.getJwt().getJwkSetUri())
.build();
decoder.setJwtValidator(validator);
return decoder;
}
@Override
public Class<?> getObjectType() {
return JwtDecoder.class;
}
}
I also extracted the AudienceValidator
from the original code to an external class and renamed it to JwtAudienceValidator
.
2) Remove the JwtDecoder
@Bean
method from the security configuration so it looks like this:
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/**")
.authenticated()
.and()
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
}
}
3) Create a Clock
bean in some @Configuration
class:
@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
(This is needed for unit testing of time expiry of tokens)
With this setup, it is now possible to write a unit test for the JwtDecoder
setup that is the actual setup the application uses:
// actual @Test methods ommitted, but they can use this private method
// to setup a JwtDecoder and test some valid/invalid JWT tokens.
@NotNull
private JwtDecoder createDecoder(String currentTime, String issuer, String audience) {
OAuth2ResourceServerProperties properties = new OAuth2ResourceServerProperties();
properties.getJwt().setJwkSetUri(
"https://mycompb2ctestorg.b2clogin.com/mycompb2ctestorg.onmicrosoft.com/discovery/v2.0/keys?p=b2c_1_ropc_flow");
JwtDecoderFactoryBean factoryBean = new JwtDecoderFactoryBean(properties,
new SecuritySettings(audience, issuer),
Clock.fixed(Instant.parse(currentTime),
ZoneId.systemDefault()));
//noinspection ConstantConditions - getObject never returns null in this case
return factoryBean.getObject();
}
Finally, the @WebMvcTest
needs to have a mock JwtDecoder
since the real one is not started anymore with the @WebMvcTest
test slice (due to the use of the factory bean). This is good IMO as otherwise, I needed to define properties for the real JwtDecoder
that did not get used anyway. As a consequence, I don't need the controller-test
profile anymore in the test.
So just declare a field like this:
@MockBean
private JwtDecoder jwtDecoder;
or create a nested test configuration class:
@TestConfiguration
static class TestConfig {
@Bean
public JwtDecoder jwtDecoder() {
return mock(JwtDecoder.class);
}
}
By using the JWT post processor .with(jwt(createJwtToken())))
you are able to bypass the JwtDecoder
.
Consider what would happen if the JwtDecoder
was not bypassed.
In the filter chain, your request would reach a point where the JwtDecoder
parses the JWT value.
In this case the value is "fake-token"
, which will result in an exception because it is not a valid JWT.
This means the code will not even reach the point where AudienceValidator
is called.
You can think of the value passed into SecurityMockMvcRequestPostProcessors.jwt(Jwt jwt)
as the response that would be returned from JwtDecoder.decode(String token)
.
Then, the tests using SecurityMockMvcRequestPostProcessors.jwt(Jwt jwt)
will test the behaviour when a valid JWT token is provided.
You can add additional tests for the AudienceValidator
to ensure that it is functioning correctly.
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