Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Test JwtDecoder in @WebMvcTest with Spring Security

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?

like image 828
Wim Deblauwe Avatar asked Nov 08 '19 08:11

Wim Deblauwe


2 Answers

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);
        }
    }
like image 104
Wim Deblauwe Avatar answered Sep 28 '22 03:09

Wim Deblauwe


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.

like image 20
Eleftheria Stein-Kousathana Avatar answered Sep 28 '22 03:09

Eleftheria Stein-Kousathana