0

I have Spring Security config that configures OAuth2 resource server and endpoints with the predefined scopes and what you can do with what endpoint. Config is below:

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests(
                        auth -> auth
                                .antMatchers("/api/v1/private/**")
                                .hasAnyAuthority("SCOPE_api:read", "SCOPE_api:write")
                                .antMatchers("/api/v1/public/**")
                                .authenticated()
                ).oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
                .httpBasic().disable()
                .csrf().disable()
                .formLogin().disable()
                .logout().disable();
        return http.build();
    }
}

There are multiple answers on the similar question on StackOverflow such as 1, 2, 3, even though I look at them, I cannot wrap my head around it and how to test the setup, correctness etc.

My questions has two parts:

  • How can I test my config? There is a nice Baeldung article but it forces to run 2 separate applications to integration tests to succeed. Great for demo, not so great for CI and development.
  • What is normally can be tested in these use-cases? What is normal to unit-test and what is normal to integration test?

I have solved similar task with testcontainers and Keycloack, but that was possible as we had in test and prod Keycloack. This time, I have no control over resource server and I don't know it's type, so it makes my head to explode.

dur
  • 15,689
  • 25
  • 79
  • 125
Dmytro Chasovskyi
  • 3,209
  • 4
  • 40
  • 82

2 Answers2

1

As with all 3rd parties, you shouldn't test a 3rd party service (at least not from your Java project). You'll just have to trust that the devs are doing their job, and that the service correctly issues a JWT.

What you want to test are OAuth scopes (aka Authorities in Spring Security). That is done by mocking your Authentication context so that it includes a principal with your required scopes.

The way I set it up in my projects is the following:

  1. Make sure you have org.springframework.security:spring-security-test as a dependency

  2. In your test source, create an annotation that will act as your fake user setup

    @Retention(RetentionPolicy.RUNTIME)
    @WithSecurityContext(factory = WithMockUserSecurityContextFactory.class)
    public @interface WithMockUser {
        long userId() default 1L;
        String[] authorities() default "ROLE_USER";
    }
    
  3. Again in your test source make a class that will mock the Security Context

    public class WithMockUserSecurityContextFactory implements WithSecurityContextFactory<WithMockUser> {
        @Override
        public SecurityContext createSecurityContext(WithMockUser annotation) {
            var context = SecurityContextHolder.createEmptyContext();
    
            var authorities = Arrays.stream(annotation.authorities())
                    .map(SimpleGrantedAuthority::new)
                    .toList();
    
            var principal = UserPrincipal.builder()
                    .userId( annotation.userId() )
                    .email("test@test.com")
                    .authorities( authorities )
                    .enabled(true)
                    .build();
    
            var auth = new UserPrincipalAuthentication(principal);
            context.setAuthentication(auth);
            return context;
        }
    }
    
    

    Note that UserPrincipalAuthentication is my custom class that extends AbstractAuthenticationToken.

  4. Now you should be able to annotate your test methods with the custom annotation and pass in the authorities you want to test for, such as this:

    @AutoConfigureMockMvc
    @SpringBootTest
    class EndpointTest {
        @Test
        @WithMockUser(authorities = "scope:READ")
        void returnsUserData_ifHasReadScope() {
            // ...
            // MockMvc calls to your endpoint, and assertions
        }
    }
    

What this does, is it completely removes the 3rd party resource server from testing scope. In our test scope we don't care which server the JWT came from, it doesn't matter how it was decoded (that can be done through other unit tests), all that matters is if Access Control is working, given that User comes with a particular set of Authorities.

Thorne
  • 186
  • 1
  • 9
  • this solution is over complicated in most cases: spring-security-test comes with MockMvc request post-processors and WebTestClient mutator which can set test security context with any Spring OAuth2 `Authentication` type (including `JwtAuthenticationToken` he is certainly using. And I maintain [a lib](https://github.com/ch4mpy/spring-addons) with annotations for the same `Authentication` implementations (plus one) – ch4mp Jan 31 '23 at 16:50
1

For end-to-end or "smoke" tests (test which include more than just your resource-server: authorization-server, REST client, etc.), their are better suited tools than Java tests, and yes, this is very likely to require you to lift containers and program robots to manipulate UIs (perform user login, fill forms, etc.).

As stated by @Thorne in his answer, testing JWT decoding and validation should not be in the scope of your Java tests (unless you write your own decoder). Also exchange with an authorization-server to issue valid access-tokens would be way to slow and fragile for a decent coverage in unit-tests (JUnit with @ExtendWith(SpringExtension.class), @WebMvcTest or @WebfluxTest) or spring-boot integration-tests (@SpringBootTest).

What I unit and integration test regarding security conf is access-control, using mocked OAuth2 identities.

spring-security-test comes with some MockMvc request post-processors (see org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt in your case) as well as WebTestClient mutators (see org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockJwt) to configure Authentication of the right type (JwtAuthenticationToken in your case) and set it in test security context, but this is limited to MockMvc and WebTestClient and as so to @Controller tests.

Sample usage with your security conf and a @GetMapping("/api/v1/private/machin") @PreAuthorize("hasAuthority('SCOPE_api:read')"):

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

@WebMvcTest(controllers = MachinController.class, properties = { "server.ssl.enabled=false" })
class MachinControllerTest {

    @Autowired
    MockMvc api;

    @Test
    void givenUserIsAnonymous_whenGetMachin_thenUnauthorized() throws Exception {
        api.perform(get("/api/v1/private/machin"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void givenUserIsGrantedWithApiRead_whenGetMachin_thenOk() throws Exception {
        api.perform(get("/api/v1/private/machin")
                .with(jwt().jwt(jwt -> jwt.authorities(List.of(new SimpleGrantedAuthority("SCOPE_api:read"))))))
            .andExpect(status().isOk());
    }

    @Test
    void givenUserIsAuthenticatedButNotGrantedWithApiRead_whenGetMachin_thenForbidden() throws Exception {
        api.perform(get("/api/v1/private/machin")
                .with(jwt().jwt(jwt -> jwt.authorities(List.of(new SimpleGrantedAuthority("SCOPE_openid"))))))
            .andExpect(status().isForbidden());
    }
}

You might also use @WithMockJwtAuth from this libs I maintain. This repo contains quite a few samples for unit and integration testing of any kind of @Component (@Controllers of course but also @Services or @Repositories decorated with method-security).

Above Sample becomes:

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-oauth2-test</artifactId>
    <version>6.0.12</version>
    <scope>test</scope>
</dependency>
@WebMvcTest(controllers = MachinController.class, properties = { "server.ssl.enabled=false" })
class MachinControllerTest {

    @Autowired
    MockMvc api;

    @Test
    void givenUserIsAnonymous_whenGetMachin_thenUnauthorized() throws Exception {
        api.perform(get("/api/v1/private/machin"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockJwtAuth("SCOPE_api:read")
    void givenUserIsGrantedWithApiRead_whenGetMachin_thenOk() throws Exception {
        api.perform(get("/api/v1/private/machin"))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockJwtAuth("SCOPE_openid")
    void givenUserIsAuthenticatedButNotGrantedWithApiRead_whenGetMachin_thenForbidden() throws Exception {
        api.perform(get("/api/v1/private/machin"))
            .andExpect(status().isForbidden());
    }
}

Spring-addons starter

In the same repo, you'll find starters to simplify your resource server security config (and also synchronize sessions and CSRF protection disabling as the second should not be disabled with active sessions...).

Usage is super simple and all you'd have to change between environments would be properties, even if using different OIDC authorization-servers, for instance a standalone Keycloak on your dev machine and a cloud provider like Cognito, Auth0, etc. in production.

Instead of directly importing spring-boot-starter-oauth2-resource-server, import a thin wrapper around it (composed of 3 files only):

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-webmvc-jwt-resource-server</artifactId>
    <version>6.0.12</version>
</dependency>

As you already apply fine grained method-security to protected routes, you could probably empty this Java conf (keep default expressionInterceptUrlRegistryPostProcessor bean which requires users to be authenticated to access any route but those listed in com.c4-soft.springaddons.security.permit-all property):

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
    @Bean
    ExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() {
        return (AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) -> registry
            .requestMatchers("/api/v1/private/**").hasAnyAuthority("SCOPE_api:read", "SCOPE_api:write")
            .anyRequest().authenticated();
    }
}

Replace spring.security.oauth2.resourceserver properties with:

# Single OIDC JWT issuer but you can add as many as you like
com.c4-soft.springaddons.security.issuers[0].location=https://localhost:8443/realms/master

# Mimic spring-security default authorities converter: map from "scope" claim (accepts a comma separated list claims), with "SCOPE_" prefix
com.c4-soft.springaddons.security.issuers[0].authorities.claims=scope
com.c4-soft.springaddons.security.issuers[0].authorities.prefix=SCOPE_

# Fine-grained CORS configuration can be set per path as follow:
com.c4-soft.springaddons.security.cors[0].path=/api/**
com.c4-soft.springaddons.security.cors[0].allowed-origins=https://localhost,https://localhost:8100,https://localhost:4200
com.c4-soft.springaddons.security.cors[0].allowedOrigins=*
com.c4-soft.springaddons.security.cors[0].allowedMethods=*
com.c4-soft.springaddons.security.cors[0].allowedHeaders=*
com.c4-soft.springaddons.security.cors[0].exposedHeaders=*

# Comma separated list of ant path matchers for resources accessible to anonymous
com.c4-soft.springaddons.security.permit-all=/api/v1/public/**
ch4mp
  • 6,622
  • 6
  • 29
  • 49
  • @Dmytro, see my updated intro and detailed section about "my" starters (and what it brings in multi-tenancy or with heterogeneous environments) – ch4mp Feb 01 '23 at 18:25