Better Testing with Spring Security Test

Better Testing with Spring Security Test

Integration testing in modern Spring Boot microservices has become easier since the release of Spring Framework 5 and Spring Security 5. Spring Framework’s WebTestClient for reactive web, and MockMvc for servlet web, allow for testing controllers in a lightweight fashion without running a server. Both frameworks leverage Spring Test mock implementations of requests and responses, allowing you to verify most of the application functionality using targeted tests.

With Spring Security 5, security test support provides new request mutators that avoid simulating a grant flow or building an access token when verifying method security in web testing.

In this tutorial, you will explore security 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:

Table of Contents

If you prefer to learn visually, you can watch a screencast of this tutorial.

Test a WebFlux Gateway with mockOidcLogin()

Let’s start by building and testing a Webflux API Gateway with Okta OIDC login enabled. With HTTPie and Spring Initializr, create and download a Spring Boot Maven project:

http -d https://start.spring.io/starter.zip type==maven-project \
  language==java \
  bootVersion==2.6.3 \
  baseDir==api-gateway \
  groupId==com.okta.developer \
  artifactId==api-gateway \
  name==api-gateway \
  packageName==com.okta.developer.gateway \
  javaVersion==11 \
  dependencies==cloud-eureka,cloud-gateway,webflux,okta,lombok

unzip api-gateway.zip

cd api-gateway

Before you begin, you’ll need a free Okta developer account. Install the Okta CLI and run okta register to sign up for a new account. If you already have an account, run okta login. Then, run okta apps create. Select the default app name, or change it as you see fit. Choose Web and press Enter.

Select Okta Spring Boot Starter. Accept the default Redirect URI values provided for you. That is, a Login Redirect of http://localhost:8080/login/oauth2/code/okta and a Logout Redirect of http://localhost:8080.

What does the Okta CLI do?

The Okta CLI will create an OIDC Web App in your Okta Org. It will add the redirect URIs you specified and grant access to the Everyone group. You will see output like the following when it’s finished:

Okta application configuration has been written to: 
  /path/to/app/src/main/resources/application.properties

Open src/main/resources/application.properties to see the issuer and credentials for your app.

okta.oauth2.issuer=https://dev-133337.okta.com/oauth2/default
okta.oauth2.client-id=0oab8eb55Kb9jdMIr5d6
okta.oauth2.client-secret=NEVER-SHOW-SECRETS

NOTE: You can also use the Okta Admin Console to create your app. See Create a Spring Boot App for more information.

Rename src/main/resources/application.properties to application.yml, and reformat to YAML syntax (making sure to remove any \ escape characters in your Okta issuer):

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}

Add the Spring Security Test dependency to the pom.xml:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

Create the package com.okta.developer.gateway.controller under src/main/java. Then create a UserData class and UserDataController to expose the OIDC ID token and access token, to use in later tests.

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;
    }
}

Create the package com.okta.developer.gateway.security under src/main/java. Add SecurityConfiguration, enabling OIDC Login and JWT authentication:

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();
    }
}

NOTE: For this tutorial, CSRF security is disabled.

Before adding the tests, disable the Eureka Client to avoid exceptions that will arise because no Eureka Server is available. Create the file src/test/resources/application-test.yml with the following content:

eureka:
  client:
    register-with-eureka: false
    fetch-registry: false

Update the class ApiGatewayApplicationTests to activate the test profile:

package com.okta.developer.gateway;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest
@ActiveProfiles("test")
class ApiGatewayApplicationTests {

    @Test
    void contextLoads() {
    }

}

Create the com.okta.developer.gateway.controller package under src/test/java. Add the first security tests with WebTestClient and mockOidcLogin():

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
@AutoConfigureWebTestClient
@ActiveProfiles("test")
public class UserDataControllerTest {

    @Autowired
    private WebTestClient client;

    @Test
    public void get_noAuth_returnsRedirectLogin() {
        this.client.get().uri("/userdata")
            .exchange()
            .expectStatus().is3xxRedirection();
    }

    @Test
    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();
    }
}

By default, @SpringBootTest loads the web ApplicationContext and provides a mock web environment.

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.

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

The test get_withOidcLogin_returnsOk() 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.

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 the embedded MongoDB instance initialized by Testcontainers. JWT access tokens are decoded, verified, and validated locally by Spring Security in the microservice.

http --download https://start.spring.io/starter.zip \
  type==maven-project \
  language==java \
  bootVersion==2.6.3 \
  baseDir==listings \
  groupId==com.okta.developer \
  artifactId==listings \
  name==listings \
  packageName==com.okta.developer.listings \
  javaVersion==11 \
  dependencies==okta,lombok,web,data-mongodb,data-rest,cloud-eureka

unzip listings.zip

Again, add the spring-security-test dependency and Testcontainers’ MongoDB Module to the pom.xml:

<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>

Rename application.properties to application.yml and set the following content:

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}

Make sure to replace {yourOktaDomain} with your Okta domain.

Create the com.okta.developer.listings.model package under src/main/java. Add a model class AirbnbListing:

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;

}

Create the package com.okta.developer.listings.repository under src/main/java. Add a AirbnbListingRepository repository:

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;

@RepositoryRestResource(collectionResourceRel = "listingsAndReviews", path="listing")
public interface AirbnbListingRepository extends MongoRepository<AirbnbListing, String> {

    @Override
    @PreAuthorize("hasAuthority('listing_admin')")
    AirbnbListing save(AirbnbListing s);

}

The annotation @RepositoryRestResource directs Spring MVC to create RESTful endpoints at the specified path. The save() operation is overridden to configure authorization, requiring the authority listing_admin.

Create the package com.okta.developer.listings.config under src/main/java. Add a RestConfiguration class for tweaking the Spring Data REST responses:

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);
    }
}

Create the package com.okta.developer.listings.security. Add SecurityConfiguration to require JWT authentication for all requests:

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);
    }
}

Update ListingsApplicationTests to enable the test profile that disables the Eureka client:

package com.okta.developer.listings;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

@SpringBootTest
@ActiveProfiles("test")
class ListingsApplicationTests {

    @Test
    void contextLoads() {
    }

}

Create src/test/resources/application-test.yml with the following content:

spring:
  cloud:
    discovery:
      enabled: false

Now, create AirbnbListingMvcTest under src/test/java to verify the authorization.

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
    public void collectionGet_noAuth_returnsUnauthorized() throws Exception {
        this.mockMvc.perform(get("/listing")).andExpect(status().isUnauthorized());
    }

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

    @Test
    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
    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();
    }

}

The test collectionGet_noAuth_returnsUnauthorized() verifies that if no JWT token is present in the request, the service will return 404 Unauthorized.

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

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

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

Try the tests with:

./mvnw test

NOTE: 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. Create a reactive microservice with OpaqueToken authentication.

http --download https://start.spring.io/starter.zip \
  type==maven-project \
  language==java \
  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

Add the Nimbus oauth2-oidc-sdk dependency to the pom.xml, 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>

Token introspection involves a call to the authorization server, so create an OIDC app with the Okta CLI, as illustrated for the api-gateway.

cd theaters

Run okta apps create. Select the default app name, or change it as you see fit. Choose Web and press Enter.

Select Okta Spring Boot Starter. Accept the default Redirect URI values provided for you. That is, a Login Redirect of http://localhost:8080/login/oauth2/code/okta and a Logout Redirect of http://localhost:8080.

What does the Okta CLI do?

The Okta CLI will create an OIDC Web App in your Okta Org. It will add the redirect URIs you specified and grant access to the Everyone group. You will see output like the following when it’s finished:

Okta application configuration has been written to: 
  /path/to/app/src/main/resources/application.properties

Open src/main/resources/application.properties to see the issuer and credentials for your app.

okta.oauth2.issuer=https://dev-133337.okta.com/oauth2/default
okta.oauth2.client-id=0oab8eb55Kb9jdMIr5d6
okta.oauth2.client-secret=NEVER-SHOW-SECRETS

NOTE: You can also use the Okta Admin Console to create your app. See Create a Spring Boot App for more information.

Rename application.properties to application.yml and set the following content:

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-secret: {yourClientSecret}
          client-id: {yourClientId}
eureka:
  client:
    service-url:
      defaultZone: ${SERVICE_URL_DEFAULT_ZONE}

Create the com.okta.developer.theaters.model package under src/main/java. Add the model class Location to map some of the fields in the dataset:

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;
}

Add the Theater model class:

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;

}

Create the package com.okta.developer.theaters.repository. Add the interface TheaterRepository:

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> {
}

Create a TheatersController in com.okta.developer.theaters.controller package:

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')")
    public Mono<Theater> saveTheater(@RequestBody Theater theater){
        return theaterRepository.save(theater);
    }

}

The POST /theater endpoint requires theater_admin authority to proceed with the persistence.

Create package com.okta.developer.theaters.security. Add a custom JwtOpaqueTokenIntrospector to parse authorities from the groups claim in the access token.

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;
    }
}

Add a SecurityConfiguration class to configure opaque token authentication.

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();
    }
}

Update TheatersApplicationTests to disable the Eureka client and to use Testcontainers for MongoDB:

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();
    }
}

Create src/test/resources/application-test.yml with the following content:

spring:
  cloud:
    discovery:
      enabled: false

Create the package com.okta.developer.theaters.controller under src/test/java. Now, create TheaterControllerTest to verify the endpoints’ authorization.

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
    public void collectionGet_noAuth_returnsUnauthorized() throws Exception {
        this.client.get().uri("/theater").exchange().expectStatus().isUnauthorized();
    }

    @Test
    public void collectionGet_withValidOpaqueToken_returnsOk() throws Exception {
        this.client.mutateWith(mockOpaqueToken())
            .get().uri("/theater").exchange().expectStatus().isOk();
    }

    @Test
    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
    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();
    }
}

The test collectionGet_noAuth_returnsUnauthorized() verifies that access is denied if there is no token in the request.

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

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

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

Again, try the tests with:

./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.

First, create a Eureka server:

http --download https://start.spring.io/starter.zip \
  type==maven-project \
  language==java \
  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

Edit EurekaApplication to add @EnableEurekaServer annotation:

package com.okta.developer.eureka;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class EurekaApplication {

    public static void main(String[] args) {
        SpringApplication.run(EurekaApplication.class, args);
    }

}

Rename src/main/resources/application.properties to application.yml and add the following content:

server:
  port: 8761

eureka:
  instance:
    hostname: localhost
  client:
    registerWithEureka: false
    fetchRegistry: false
    serviceUrl:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/

Configure theater and listing routes in the api-gateway project. Edit ApiGatewayApplication to add a RouteLocator bean:

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();
    }
}

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:

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

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

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

Place the files in some location and update {mongoDataPath} to use it in the docker-compose.yml file.

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

mongorestore -d airbnb /db-dump

Build each service image with:

./mvnw spring-boot:build-image

Run the services with Docker Compose:

cd docker
docker compose up

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

Eureka Instances Registered

Go to http://localhost:8080/userdata after the Okta login, and you should see an output similar to this:

{
   "userName":"...",
   "idToken":"...",
   "accessToken":"..."
}

Test the api-gateway endpoints http://localhost:8080/theater and http://localhost:8080/listing with your browser.

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}"

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. 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.

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 .*)

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!

If it doesn’t work, you can use token.dev to view the contents of your access token.

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

Rebuild the listings service image.

cd listings
./mvnw spring-boot:build-image -DskipTests

Restart the services and repeat the HTTPie POST request:

http POST http://localhost:8080/listing name=test "Authorization:Bearer ${ACCESS_TOKEN}"

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"

You can verify the same in the theaters service.

Learn More About Spring Security and OAuth

I hope you enjoyed this tutorial and understand more about SecurityMockServerConfigurers in reactive test support and SecurityMockMvcRequestPostProcessors in the MockMvc test support (available since Spring Security 5). You can see how useful these can be for integration testing, as well as the limitations of request and response mocking.

You can find all the code from this tutorial on GitHub, in the okta-spring-security-test-example repository.

Check out the links below to learn more about Spring Security and OAuth 2.0 patterns:

If you’d like to see more information like this, consider following us on Twitter and subscribing to our YouTube channel. We’ve also been streaming on Twitch a bit lately.

Changelog:

Okta Developer Blog Comment Policy

We welcome relevant and respectful comments. Off-topic comments may be removed.