Skip to content

Latest commit

 

History

History
1299 lines (1101 loc) · 41.8 KB

File metadata and controls

1299 lines (1101 loc) · 41.8 KB

Spring Security Test Demo Steps

In this demo, I’ll show how to use Spring Security’s mocking with SecurityMockServerConfigurers and SecurityMockMvcRequestPostProcessors, as well as authorization tests for the following patterns:

  • Reactive WebFlux gateway with OIDC authentication

  • Servlet MVC REST API with JWT authorization

  • Reactive WebFlux REST API with OpaqueToken authorization

Prerequisites:

Tip
The brackets at the end of some steps indicate the IntelliJ Live Templates to use. You can find the template definitions at mraible/idea-live-templates.

Test a WebFlux Gateway with mockOidcLogin()

  1. Create and download a Spring Boot project:

    http -d https://start.spring.io/starter.zip \
      bootVersion==2.6.3 \
      baseDir==api-gateway \
      groupId==com.okta.developer \
      artifactId==api-gateway \
      name==api-gateway \
      packageName==com.okta.developer.gateway \
      dependencies==cloud-eureka,cloud-gateway,webflux,okta,lombok
    
    unzip api-gateway.zip
    
    cd api-gateway
  2. Install the Okta CLI and run okta register to sign up for a new account. If you already have an account, run okta login.

    Run okta apps create. Select the default app name, or change it as you see fit. Choose Web > Okta Spring Boot Starter. Accept the default redirect URI values provided for you.

  3. Rename src/main/resources/application.properties to application.yml, and reformat to YAML syntax: [sst-gateway-config]

    spring:
      application:
        name: gateway
      cloud:
        gateway:
          discovery:
            locator:
              enabled: true
    okta:
      oauth2:
        issuer: https://{yourOktaDomain}/oauth2/default
        client-id: {clientId}
        client-secret: {clientSecret}
        scopes: openid, profile, email
    
    eureka:
      client:
        service-url:
          defaultZone: ${SERVICE_URL_DEFAULT_ZONE}
  4. Add the Spring Security Test dependency to pom.xml:

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
  5. Create a com.okta.developer.gateway.controller package. Then create a UserData class and UserDataController to expose the OIDC ID token and access token, to use in later tests. [sst-gateway-user, sst-gateway-usercontroller]

    package com.okta.developer.gateway.controller;
    
    import lombok.Data;
    
    @Data
    public class UserData {
    
        private String userName;
        private String idToken;
        private String accessToken;
    }
    package com.okta.developer.gateway.controller;
    
    import org.springframework.security.core.annotation.AuthenticationPrincipal;
    import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
    import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
    import org.springframework.security.oauth2.core.oidc.user.OidcUser;
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    @Controller
    public class UserDataController {
    
        @RequestMapping("/userdata")
        @ResponseBody
        public UserData greeting(@AuthenticationPrincipal OidcUser oidcUser,
                                 @RegisteredOAuth2AuthorizedClient("okta") OAuth2AuthorizedClient client) {
    
            UserData userData = new UserData();
            userData.setUserName(oidcUser.getFullName());
            userData.setIdToken(oidcUser.getIdToken().getTokenValue());
            userData.setAccessToken(client.getAccessToken().getTokenValue());
            return userData;
        }
    }
  6. Create com.okta.developer.gateway.security.SecurityConfiguration to enable OIDC Login and JWT authentication: [sst-gateway-security]

    package com.okta.developer.gateway.security;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
    import org.springframework.security.config.web.server.ServerHttpSecurity;
    import org.springframework.security.web.server.SecurityWebFilterChain;
    
    @Configuration
    @EnableWebFluxSecurity
    public class SecurityConfiguration {
    
        @Bean
        public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
            http.csrf().disable()
                .authorizeExchange()
                .anyExchange()
                .authenticated()
                .and().oauth2Login()
                .and().oauth2ResourceServer().jwt();
            return http.build();
        }
    }
  7. Before adding the tests, disable the Eureka Client to avoid exceptions that will arise because no Eureka Server is available. Create src/test/resources/application-test.yml with the following content:

    eureka:
      client:
        register-with-eureka: false
        fetch-registry: false
  8. Update ApiGatewayApplicationTests to activate the test profile:

    import org.springframework.test.context.ActiveProfiles;
    
    @SpringBootTest
    @ActiveProfiles("test")
    class ApiGatewayApplicationTests { ... }
  9. Create a com.okta.developer.gateway.controller package under src/test/java. Add a UserDataControllerTest class with tests using WebTestClient and mockOidcLogin(): [sst-gateway-test]

    package com.okta.developer.gateway.controller;
    
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.ActiveProfiles;
    import org.springframework.test.web.reactive.server.WebTestClient;
    
    import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOidcLogin;
    
    @SpringBootTest // (1)
    @AutoConfigureWebTestClient // (2)
    @ActiveProfiles("test")
    public class UserDataControllerTest {
    
        @Autowired
        private WebTestClient client;
    
        @Test // (3)
        public void get_noAuth_returnsRedirectLogin() {
            this.client.get().uri("/userdata")
                .exchange()
                .expectStatus().is3xxRedirection();
        }
    
        @Test // (4)
        public void get_withOidcLogin_returnsOk() {
            this.client.mutateWith(mockOidcLogin().idToken(token -> token.claim("name", "Mock User")))
                .get().uri("/userdata")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.userName").isNotEmpty()
                .jsonPath("$.idToken").isNotEmpty()
                .jsonPath("$.accessToken").isNotEmpty();
        }
    }
    1. By default, @SpringBootTest loads the web ApplicationContext and provides a mock web environment.

    2. With @AutoConfigureWebTestClient, Spring Boot initializes a WebTestClient that can be injected into the test classes. The alternative for mock web testing is @WebFluxTest, which also configures a WebTestClient, but the test is limited to a single controller, and collaborators need to be mocked.

    3. The get_noAuth_returnsRedirectLogin() test verifies that the server will redirect to the OIDC Login flow if no authentication is present.

    4. The get_withOidcLogin_returnsOk() test configures the mock request with an OidcUser, using mockOidcLogin(). The mock OidcUser.idToken is modified by adding the name claim because UserDataController expects it for populating the response. mockOidcLogin() belongs to a set of SecurityMockServerConfigurers that ship with Spring Security Test 5 as part of the reactive test support features.

  10. Run the tests with:

    ./mvnw test

Test an MVC Resource Server with jwt() Mocking and Testcontainers

Now, let’s create a JWT microservice for lodge listings using Spring Data REST. On application load, a sample dataset will be seeded to an embedded MongoDB instance initialized by Testcontainers. JWT access tokens are decoded, verified, and validated locally by Spring Security in the microservice.

  1. Create a new Spring Boot app with MongoDB, Spring Data REST, and Eureka support.

    http --download https://start.spring.io/starter.zip \
      bootVersion==2.6.3 \
      baseDir==listings \
      groupId==com.okta.developer \
      artifactId==listings \
      name==listings \
      packageName==com.okta.developer.listings \
      dependencies==okta,lombok,web,data-mongodb,data-rest,cloud-eureka
    
    unzip listings.zip
  2. Add the spring-security-test dependency and Testcontainers' MongoDB Module:

    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>mongodb</artifactId>
        <version>1.16.3</version>
        <scope>test</scope>
    </dependency>
  3. Rename application.properties to application.yml and set the following content: [sst-listings-config]

    server:
      port: 8081
    
    spring:
      application:
        name: listing
      data:
        mongodb:
          port: 27017
          database: airbnb
    okta:
      oauth2:
        issuer: https://{yourOktaDomain}/oauth2/default
    
    eureka:
      client:
        service-url:
          defaultZone: ${SERVICE_URL_DEFAULT_ZONE}
    Caution
    Make sure to replace {yourOktaDomain} with your Okta domain!
  4. Create the com.okta.developer.listings.model package under src/main/java. Add a model class AirbnbListing: [sst-listings-model]

    src/main/java/com/okta/developer/listings/model/AirbnbListing.java
    package com.okta.developer.listings.model;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.springframework.data.annotation.Id;
    import org.springframework.data.mongodb.core.mapping.Document;
    import org.springframework.data.mongodb.core.mapping.Field;
    
    @Document(collection = "listingsAndReviews")
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class AirbnbListing {
    
        @Id
        private String id;
        private String name;
        private String summary;
        @Field(name = "property_type")
        private String propertyType;
        @Field(name = "room_type")
        private String roomType;
        @Field(name = "bed_type")
        private String bedType;
        @Field(name = "cancellation_policy")
        private String cancellationPolicy;
    
    }
  5. Create a com.okta.developer.listings.repository package under src/main/java. Add a AirbnbListingRepository repository: [sst-listings-repo]

    package com.okta.developer.listings.repository;
    
    import com.okta.developer.listings.model.AirbnbListing;
    import org.springframework.data.mongodb.repository.MongoRepository;
    import org.springframework.data.rest.core.annotation.RepositoryRestResource;
    import org.springframework.security.access.prepost.PreAuthorize;
    
    // (1)
    @RepositoryRestResource(collectionResourceRel = "listingsAndReviews", path="listing")
    public interface AirbnbListingRepository extends MongoRepository<AirbnbListing, String> {
    
        @Override
        @PreAuthorize("hasAuthority('listing_admin')") // (2)
        AirbnbListing save(AirbnbListing s);
    
    }
    1. The annotation @RepositoryRestResource directs Spring MVC to create RESTful endpoints at the specified path.

    2. The save() operation is overridden to configure authorization, requiring the authority listing_admin.

  6. Create a com.okta.developer.listings.config package. Add a RestConfiguration class for tweaking the Spring Data REST responses: [sst-listings-restconfig]

    src/main/java/com/okta/developer/listings/config/RestConfiguration.java
    package com.okta.developer.listings.config;
    
    import com.okta.developer.listings.model.AirbnbListing;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
    
    import javax.annotation.PostConstruct;
    
    @Configuration
    public class RestConfiguration {
    
        @Autowired
        private RepositoryRestConfiguration repositoryRestConfiguration;
    
        @PostConstruct
        public void setUp(){
            this.repositoryRestConfiguration.setReturnBodyOnCreate(true);
            this.repositoryRestConfiguration.exposeIdsFor(AirbnbListing.class);
        }
    }
  7. Create com.okta.developer.listings.security.SecurityConfiguration to require JWT authentication for all requests: [sst-listings-security]

    src/main/java/com/okta/developer/listings/security/SecurityConfiguration.java
    package com.okta.developer.listings.security;
    
    import com.okta.spring.boot.oauth.Okta;
    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;
    
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                .oauth2ResourceServer().jwt();
    
            Okta.configureResourceServer401ResponseBody(http);
        }
    }
  8. Update ListingsApplicationTests to enable the test profile that disables the Eureka client:

    import org.springframework.test.context.ActiveProfiles;
    
    @SpringBootTest
    @ActiveProfiles("test")
    class ListingsApplicationTests { ... }
  9. Create src/test/resources/application-test.yml:

    spring:
      cloud:
        discovery:
          enabled: false
  10. Now, create AirbnbListingMvcTest to verify the authorization. [sst-listings-test]

    package com.okta.developer.listings;
    
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.okta.developer.listings.model.AirbnbListing;
    import org.junit.jupiter.api.AfterAll;
    import org.junit.jupiter.api.BeforeAll;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.test.context.ActiveProfiles;
    import org.springframework.test.web.servlet.MockMvc;
    import org.testcontainers.containers.MongoDBContainer;
    import org.testcontainers.utility.DockerImageName;
    
    import java.util.List;
    
    import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
    import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
    
    @SpringBootTest
    @AutoConfigureMockMvc
    @ActiveProfiles("test")
    public class AirbnbListingMvcTest {
    
        @Autowired
        private MockMvc mockMvc;
    
        @Autowired
        private ObjectMapper objectMapper;
    
        private static final MongoDBContainer mongoDBContainer =
            new MongoDBContainer(DockerImageName.parse("mongo:bionic"))
                .withExposedPorts(27017)
                .withEnv("MONGO_INIT_DATABASE", "airbnb");
    
        @BeforeAll
        public static void setUp() {
            mongoDBContainer.setPortBindings(List.of("27017:27017"));
            mongoDBContainer.start();
        }
    
        @Test // (1)
        public void collectionGet_noAuth_returnsUnauthorized() throws Exception {
            this.mockMvc.perform(get("/listing")).andExpect(status().isUnauthorized());
        }
    
        @Test // (2)
        public void collectionGet_withValidJwtToken_returnsOk() throws Exception {
            this.mockMvc.perform(get("/listing").with(jwt())).andExpect(status().isOk());
        }
    
        @Test // (3)
        public void save_withMissingAuthorities_returnsForbidden() throws Exception {
            AirbnbListing listing = new AirbnbListing();
            listing.setName("test");
            String json = objectMapper.writeValueAsString(listing);
            this.mockMvc.perform(post("/listing").content(json).with(jwt()))
                .andExpect(status().isForbidden());
        }
    
        @Test // (4)
        public void save_withValidJwtToken_returnsCreated() throws Exception {
            AirbnbListing listing = new AirbnbListing();
            listing.setName("test");
            String json = objectMapper.writeValueAsString(listing);
            this.mockMvc.perform(post("/listing").content(json).with(jwt()
                    .authorities(new SimpleGrantedAuthority("listing_admin"))))
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").isNotEmpty());
        }
    
        @AfterAll
        public static void tearDown() {
            mongoDBContainer.stop();
        }
    
    }
    1. The collectionGet_noAuth_returnsUnauthorized() test verifies that if no JWT token is present in the request, the service will return 404 Unauthorized.

    2. The collectionGet_withValidJwtToken_returnsOk() test verifies that with valid JWT authentication, the /listing GET returns 200 Ok.

    3. The save_withMissingAuhtorities_returnsForbidden() test verifies that if the JWT lacks the listing_admin authority, the save operation is denied with 403 Forbidden.

    4. The save_withValidJwtToken_returnsCreated() test mocks a JWT with the required authority, verifies the save operation succeeds, and returns 201 Created.

  11. Try the tests with:

    ./mvnw test
    Caution
    If you see MongoSocketReadException: Prematurely reached end of stream in the test logs, you can ignore that for now. It might be because the MongoDB Testcontainer shuts down before the context.

Test a WebFlux Resource Server with mockOpaqueToken()

The OpaqueToken is validated remotely with a request to the authorization server.

  1. Create a reactive microservice with OpaqueToken authentication.

    http --download https://start.spring.io/starter.zip \
      bootVersion==2.6.3 \
      baseDir==theaters \
      groupId==com.okta.developer \
      artifactId==theaters \
      name==theaters \
      packageName==com.okta.developer.theaters \
      javaVersion==11 \
      dependencies==lombok,devtools,data-mongodb-reactive,webflux,oauth2-resource-server,cloud-eureka
    
    unzip theaters.zip
  2. Add the Nimbus oauth2-oidc-sdk dependency, required for token introspection, and add the spring-security-test dependency.

    <dependency>
        <groupId>com.nimbusds</groupId>
        <artifactId>oauth2-oidc-sdk</artifactId>
        <version>9.25</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>mongodb</artifactId>
        <version>1.16.3</version>
        <scope>test</scope>
    </dependency>
  3. Token introspection involves a call to the authorization server, so create an OIDC app with the Okta CLI.

    cd theaters
    okta apps create
    # select Okta Spring Boot starter
  4. Rename application.properties to application.yml and configure as follows: [sst-theaters-config]

    server:
      port: 8082
    
    spring:
      application:
        name: theater
      data:
        mongodb:
          port: 27017
          database: airbnb
      security:
        oauth2:
          resourceserver:
            opaque-token:
              introspection-uri: https://{yourOktaDomain}/oauth2/default/v1/introspect
              client-id: {yourClientId}
              client-secret: {yourClientSecret}
    eureka:
      client:
        service-url:
          defaultZone: ${SERVICE_URL_DEFAULT_ZONE}
  5. Create com.okta.developer.theaters.model.Location to map some of the fields in the dataset: [sst-theaters-location]

    src/main/java/com/okta/developer/theaters/model/Location.java
    package com.okta.developer.theaters.model;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.springframework.data.mongodb.core.geo.GeoJsonPoint;
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class Location {
    
        private GeoJsonPoint geo;
    }
  6. Add the Theater model class: [sst-theaters-theater]

    src/main/java/com/okta/developer/theaters/model/Theater.java
    package com.okta.developer.theaters.model;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.springframework.data.annotation.Id;
    import org.springframework.data.mongodb.core.mapping.Document;
    
    @Document("theaters")
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class Theater {
    
        @Id
        private String id;
        private Location location;
    
    }
  7. Create a com.okta.developer.theaters.repository package. Add the interface TheaterRepository: [sst-theaters-repo]

    package com.okta.developer.theaters.repository;
    
    import com.okta.developer.theaters.model.Theater;
    import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
    
    public interface TheaterRepository extends ReactiveMongoRepository<Theater, String> {
    }
  8. Create a TheaterController in the com.okta.developer.theaters.controller package: [sst-theaters-controller]

    package com.okta.developer.theaters.controller;
    
    import com.okta.developer.theaters.repository.TheaterRepository;
    import com.okta.developer.theaters.model.Theater;
    import org.springframework.http.HttpStatus;
    import org.springframework.security.access.prepost.PreAuthorize;
    import org.springframework.web.bind.annotation.*;
    import reactor.core.publisher.Flux;
    import reactor.core.publisher.Mono;
    
    @RestController
    public class TheaterController {
    
        private TheaterRepository theaterRepository;
    
        public TheaterController(TheaterRepository theaterRepository){
            this.theaterRepository = theaterRepository;
        }
    
        @GetMapping("/theater")
        public Flux<Theater> getAllTheaters(){
            return theaterRepository.findAll();
        }
    
        @PostMapping("/theater")
        @ResponseStatus(HttpStatus.CREATED)
        @PreAuthorize("hasAuthority('theater_admin')") // (1)
        public Mono<Theater> saveTheater(@RequestBody Theater theater){
            return theaterRepository.save(theater);
        }
    
    }
    1. The POST /theater endpoint requires theater_admin authority to proceed with the persistence.

  9. Create a com.okta.developer.theaters.security package. Add a custom JwtOpaqueTokenIntrospector to parse authorities from the groups claim in the access token. [sst-theaters-jwt]

    src/main/java/com/okta/developer/theaters/security/JwtOpaqueTokenIntrospector.java
    package com.okta.developer.theaters.security;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal;
    import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
    import org.springframework.security.oauth2.server.resource.introspection.NimbusReactiveOpaqueTokenIntrospector;
    import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector;
    import reactor.core.publisher.Mono;
    
    import javax.annotation.PostConstruct;
    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.List;
    
    public class JwtOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector {
    
        @Autowired
        private OAuth2ResourceServerProperties oAuth2;
        private ReactiveOpaqueTokenIntrospector delegate;
    
        @PostConstruct
        private void setUp() {
            delegate =
                new NimbusReactiveOpaqueTokenIntrospector(
                    oAuth2.getOpaquetoken().getIntrospectionUri(),
                    oAuth2.getOpaquetoken().getClientId(),
                    oAuth2.getOpaquetoken().getClientSecret());
        }
    
        public Mono<OAuth2AuthenticatedPrincipal> introspect(String token) {
            return this.delegate.introspect(token)
                .flatMap(principal -> enhance(principal));
        }
    
        private Mono<OAuth2AuthenticatedPrincipal> enhance(OAuth2AuthenticatedPrincipal principal) {
            Collection<GrantedAuthority> authorities = extractAuthorities(principal);
            OAuth2AuthenticatedPrincipal enhanced =
                new DefaultOAuth2AuthenticatedPrincipal(principal.getAttributes(), authorities);
            return Mono.just(enhanced);
        }
    
        private Collection<GrantedAuthority> extractAuthorities(OAuth2AuthenticatedPrincipal principal) {
            Collection<GrantedAuthority> authorities = new ArrayList<>();
            authorities.addAll(principal.getAuthorities());
    
            List<String> groups = principal.getAttribute("groups");
            if (groups != null) {
                groups.stream()
                    .map(SimpleGrantedAuthority::new)
                    .forEach(authorities::add);
            }
    
            return authorities;
        }
    }
  10. Add a SecurityConfiguration class to configure opaque token authentication. [sst-theaters-security]

    src/main/java/com/okta/developer/theaters/security/SecurityConfiguration.java
    package com.okta.developer.theaters.security;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
    import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
    import org.springframework.security.config.web.server.ServerHttpSecurity;
    import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector;
    import org.springframework.security.web.server.SecurityWebFilterChain;
    
    @EnableWebFluxSecurity
    @EnableReactiveMethodSecurity
    public class SecurityConfiguration {
    
        @Bean
        public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
            return http.csrf().disable()
                .authorizeExchange()
                .anyExchange().authenticated()
                .and()
                .oauth2ResourceServer()
                .opaqueToken().and().and().build();
        }
    
        @Bean
        public ReactiveOpaqueTokenIntrospector introspector() {
            return new JwtOpaqueTokenIntrospector();
        }
    }
  11. Update TheatersApplicationTests to disable the Eureka client and to use Testcontainers for MongoDB: [sst-theaters-apptests]

    src/test/java/com/okta/developer/theaters/TheatersApplicationTests.java
    package com.okta.developer.theaters;
    
    import org.junit.jupiter.api.AfterAll;
    import org.junit.jupiter.api.BeforeAll;
    import org.junit.jupiter.api.Test;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.ActiveProfiles;
    import org.testcontainers.containers.MongoDBContainer;
    import org.testcontainers.utility.DockerImageName;
    
    import java.util.List;
    
    @SpringBootTest
    @ActiveProfiles("test")
    class TheatersApplicationTests {
    
        private static final MongoDBContainer mongoDBContainer =
            new MongoDBContainer(DockerImageName.parse("mongo:bionic"))
                .withExposedPorts(27017)
                .withEnv("MONGO_INIT_DATABASE", "airbnb");
    
        @BeforeAll
        public static void setUp() {
            mongoDBContainer.setPortBindings(List.of("27017:27017"));
            mongoDBContainer.start();
        }
    
        @Test
        void contextLoads() {
        }
    
        @AfterAll
        public static void tearDown() {
            mongoDBContainer.stop();
        }
    }
  12. Create src/test/resources/application-test.yml with the following content:

    spring:
      cloud:
        discovery:
          enabled: false
  13. Create com.okta.developer.theaters.controller.TheaterControllerTest under src/test/java to verify the endpoints' authorization. [sst-theaters-controllertest]

    package com.okta.developer.theaters.controller;
    
    import com.okta.developer.theaters.model.Location;
    import com.okta.developer.theaters.model.Theater;
    import org.junit.jupiter.api.AfterAll;
    import org.junit.jupiter.api.BeforeAll;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.test.context.ActiveProfiles;
    import org.springframework.test.web.reactive.server.WebTestClient;
    import org.testcontainers.containers.MongoDBContainer;
    import org.testcontainers.utility.DockerImageName;
    
    import java.util.List;
    
    import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.mockOpaqueToken;
    import static org.springframework.web.reactive.function.BodyInserters.fromValue;
    
    @SpringBootTest
    @AutoConfigureWebTestClient
    @ActiveProfiles("test")
    public class TheaterControllerTest {
    
        @Autowired
        private WebTestClient client;
    
        private static final MongoDBContainer mongoDBContainer =
            new MongoDBContainer(DockerImageName.parse("mongo:bionic"))
                .withExposedPorts(27017)
                .withEnv("MONGO_INIT_DATABASE", "airbnb");
    
        @BeforeAll
        public static void setUp() {
            mongoDBContainer.setPortBindings(List.of("27017:27017"));
            mongoDBContainer.start();
        }
    
        @Test // (1)
        public void collectionGet_noAuth_returnsUnauthorized() throws Exception {
            this.client.get().uri("/theater").exchange().expectStatus().isUnauthorized();
        }
    
        @Test // (2)
        public void collectionGet_withValidOpaqueToken_returnsOk() throws Exception {
            this.client.mutateWith(mockOpaqueToken())
                .get().uri("/theater").exchange().expectStatus().isOk();
        }
    
        @Test // (3)
        public void post_withMissingAuthorities_returnsForbidden() throws Exception {
            Theater theater = new Theater();
            theater.setId("123");
            theater.setLocation(new Location());
            this.client.mutateWith(mockOpaqueToken())
                .post().uri("/theater").body(fromValue(theater))
                .exchange().expectStatus().isForbidden();
        }
    
        @Test // (4)
        public void post_withValidOpaqueToken_returnsCreated() throws Exception {
            Theater theater = new Theater();
            theater.setLocation(new Location());
            this.client.mutateWith(
                    mockOpaqueToken().authorities(new SimpleGrantedAuthority("theater_admin")))
                .post().uri("/theater").body(fromValue(theater))
                .exchange()
                .expectStatus().isCreated()
                .expectBody().jsonPath("$.id").isNotEmpty();
        }
    
        @AfterAll
        public static void tearDown() {
            mongoDBContainer.stop();
        }
    }
    1. The collectionGet_noAuth_returnsUnauthorized() test verifies that access is denied if there is no token in the request.

    2. The collectionGet_withValidOpaqueToken_returnsOk() test sets a mock opaque token in the request, so the controller must return 200 OK.

    3. The post_withMissingAuthorities_returnsFodbidden() test verifies that without the required authorities, the controller rejects the request with 403 Forbidden.

    4. The post_withValidOpaqueToken_returnsCreated() test verifies that if theater_admin authority is present in the token, the create request will pass, returning the new theater in the response body.

  14. Run the tests:

    ./mvnw test

On Mocking Features in Spring Security Test

Spring Security Test documentation indicates that when testing with WebTestClient and mockOpaqueToken() (or any other configurer), the request will pass correctly through any authentication API, and the mock authentication object will be available for the authorization mechanism to verify. The same applies for MockMvc. That is likely why an invalid audience, expiration, or issuer in the token attributes is ignored in this kind of test.

For example, the following AirbnbListingMvcTest test will pass:

@Test
public void collectionGet_withInvalidJWtToken_returnsOk() throws Exception {
    this.mockMvc.perform(get("/listing").with(jwt()
    .jwt(jwt -> jwt.claim("exp", Instant.MIN)
            .claim("iss", "invalid")
            .claim("aud", "invalid")))).andExpect(status().isOk());
}

In the same way, if the WebTestClient or MockMvc mocks a different type of authentication than expected, the test might pass as long as the controller injects a compatible authentication type. The test will pass depending on which method the test is expecting to be in the SecurityContextHolder. For example, the listings service expects JWT authentication, but the following AirbnbListingMvcTest test will pass:

@Test
public void collectionGet_withOpaqueToken_returnsOk() throws Exception {
    this.mockMvc.perform(get("/listing").with(opaqueToken())).andExpect(status().isOk());
}

Verify Authorization and Audience Validation

Let’s run an end-to-end test using HTTPie to verify both the authorization and that the audience is enforced in both services.

  1. First, create a Eureka server:

    http --download https://start.spring.io/starter.zip \
      bootVersion==2.6.3 \
      baseDir==eureka \
      groupId==com.okta.developer \
      artifactId==eureka \
      name==eureka \
      packageName==com.okta.developer.eureka \
      javaVersion==11 \
      dependencies==cloud-eureka-server
    
    unzip eureka.zip
  2. Edit EurekaApplication to add an @EnableEurekaServer annotation:

    import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
    
    @SpringBootApplication
    @EnableEurekaServer
    public class EurekaApplication { ... }
  3. Rename src/main/resources/application.properties to application.yml and add the following content: [sst-eureka-config]

    server:
      port: 8761
    
    eureka:
      instance:
        hostname: localhost
      client:
        registerWithEureka: false
        fetchRegistry: false
        serviceUrl:
          defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
  4. Configure theater and listing routes in the api-gateway project. Edit ApiGatewayApplication to add a RouteLocator bean: [sst-gateway-routes]

    src/test/java/com/okta/developer/gateway/ApiGatewayApplication.java
    package com.okta.developer.gateway;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.cloud.gateway.filter.factory.TokenRelayGatewayFilterFactory;
    import org.springframework.cloud.gateway.route.RouteLocator;
    import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
    import org.springframework.context.annotation.Bean;
    
    @SpringBootApplication
    public class ApiGatewayApplication {
    
        @Autowired
        private TokenRelayGatewayFilterFactory filterFactory;
    
        public static void main(String[] args) {
            SpringApplication.run(ApiGatewayApplication.class, args);
        }
    
        @Bean
        public RouteLocator routeLocator(RouteLocatorBuilder builder) {
            return builder.routes()
                .route("listing", r -> r.path("/listing/**")
                    .filters(f -> f.filter(filterFactory.apply()))
                    .uri("lb://listing"))
                .route("theater", r -> r.path("/theater/**")
                    .filters(f -> f.filter(filterFactory.apply()))
                    .uri("lb://theater"))
                .build();
        }
    }
  5. Create a docker folder at the root level (same level as the api-gateway, theaters, and listings), where all services are contained. Add a docker-compose.yml file with the following content: [sst-docker]

    docker-compose.yml
    version: "3.8"
    
    services:
      mongo:
        image: mongo:bionic
        hostname: mongo
        environment:
          - MONGO_INIT_DATABASE=airbnb
        ports:
          - "27017:27017"
        volumes:
          - ./initdb.sh:/docker-entrypoint-initdb.d/initdb.sh
          - /{mongoDataPath}:/db-dump
      api-gateway:
        image: api-gateway:0.0.1-SNAPSHOT
        ports:
          - "8080:8080"
        depends_on:
          - eureka
        environment:
          - SERVICE_URL_DEFAULT_ZONE=http://eureka:8761/eureka
      listings:
        image: listings:0.0.1-SNAPSHOT
        ports:
          - "8081:8081"
        depends_on:
          - mongo
          - eureka
        environment:
          - SERVICE_URL_DEFAULT_ZONE=http://eureka:8761/eureka
          - SPRING_DATA_MONGODB_HOST=mongo
      eureka:
        image: eureka:0.0.1-SNAPSHOT
        hostname: eureka
        ports:
          - "8761:8761"
        environment:
          - EUREKA_INSTANCE_HOSTNAME=eureka
      theaters:
        image: theaters:0.0.1-SNAPSHOT
        ports:
          - "8082:8082"
        depends_on:
          - mongo
          - eureka
        environment:
          - SERVICE_URL_DEFAULT_ZONE=http://eureka:8761/eureka
          - SPRING_DATA_MONGODB_HOST=mongo
  6. Get the MongoDB dump files theaters.bson, theaters.metadata.json from GitHub.

    http -d https://github.com/huynhsamha/quick-mongo-atlas-datasets/blob/master/dump/sample_mflix/theaters.bson?raw=true
    http -d https://github.com/huynhsamha/quick-mongo-atlas-datasets/blob/master/dump/sample_mflix/theaters.metadata.json?raw=true
  7. Also get listingsAndReviews.bson and listingsAndreviews.metadata.json from GitHub.

    http -d https://github.com/huynhsamha/quick-mongo-atlas-datasets/blob/master/dump/sample_airbnb/listingsAndReviews.bson?raw=true
    http -d https://github.com/huynhsamha/quick-mongo-atlas-datasets/blob/master/dump/sample_airbnb/listingsAndReviews.metadata.json?raw=true
  8. Place the files in some location and update {mongoDataPath} to use it in the docker-compose.yml file.

  9. Create a file docker/initdb.sh with the following script:

    mongorestore -d airbnb /db-dump
  10. Build each service image with:

    ./mvnw spring-boot:build-image
  11. Run the services with Docker Compose:

    cd docker
    docker compose up
  12. Go to http://localhost:8761, and you should see the Eureka home. (Wait for all services to register.)

  13. Go to http://localhost:8080/userdata, and you should see an output similar to this:

    {
       "userName":"...",
       "idToken":"...",
       "accessToken":"..."
    }
  14. Test the api-gateway endpoints http://localhost:8080/theater and http://localhost:8080/listing with your browser.

  15. Now, let’s test authorization with a POST to the /listing endpoint. Copy the accessToken value from the /userdata output and set it as an environment variable:

    ACCESS_TOKEN={accessToken}
    
    http POST http://localhost:8080/listing name=test "Authorization:Bearer ${ACCESS_TOKEN}"
  16. You will see the following response:

    HTTP/1.1 403 Forbidden
    WWW-Authenticate: Bearer error="insufficient_scope",
     error_description="The request requires higher privileges than provided by the access token.",
     error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

    This is because the listings service expects listing_admin authority to accept the POST request. The Okta Spring Boot Starter will automatically assign the content of the groups claim as authorities.

  17. Login to the Okta Admin Console (running okta login will get you the URL), create a listing_admin group (Directory > Groups), and assign your user to it.

  18. Then, add the groups claim to the access token. Go to Security > API. Select the default authorization server. Go to Claims, and add a claim. Set the following values:

    • Name: groups

    • Include in token type: Access Token

    • Value type: Groups

    • Filter: Matches regex (set filter value to .*)

  19. Open an incognito window, and request the /userdata endpoint, to repeat the sign-in and obtain a new access token with the groups claim. Repeat the HTTPie POST request, and now your access token should be accepted!

  20. Stop the services with CTRL-C and change the expected audience in the listings project’s application.yml:

    okta:
      oauth2:
        issuer: https://{yourOktaDomain}/oauth2/default
        audience: api://custom
  21. Rebuild the listings service image.

    cd listings
    ./mvnw spring-boot:build-image -DskipTests
  22. Restart the services and repeat the HTTPie POST request:

    http POST http://localhost:8080/listing name=test "Authorization:Bearer ${ACCESS_TOKEN}"
  23. You will see the following response:

    HTTP/1.1 401 Unauthorized
    WWW-Authenticate: Bearer error="invalid_token",
     error_description="An error occurred while attempting to decode the Jwt: This aud claim is not equal to the configured audience",
     error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

Build better tests with Spring Security!

I hope you enjoyed this screencast and understand more about SecurityMockServerConfigurers in Spring Security’s WebFlux test support and SecurityMockMvcRequestPostProcessors in the Spring MVC test support.

🍃 Find the code on GitHub: @oktadev/okta-spring-security-test-example

👀 Read the blog post: Better Testing with Spring Security Test