Securing Spring Boot Microservice using Keycloak and Testcontainers

  • Java
  • Spring Boot
  • Security
  • Keycloak
Get the code

In this guide, you will learn how to

  • Create an OAuth 2.0 Resource Server using Spring Boot

  • Secure the API endpoints using Keycloak

  • Test the APIs using Testcontainers Keycloak module

  • Run the application locally using Testcontainers Keycloak module

Prerequisites

What we are going to achieve in this guide

We are going to create a Spring Boot application as an OAuth 2.0 Resource Server, and we are going to secure it using Keycloak. We will implement an API endpoint to create a new product and configure Spring Security to protect the API endpoint using OAuth 2.0 JWT token-based authorization.

We will explore how to use the Testcontainers Keycloak module for testing the API endpoint and also for local development.

Getting Started

We can use Spring Security OAuth 2 features to create a Spring Boot OAuth 2.0 Resource Server, and protect it using OAuth Service Providers like Keycloak, Okta, Auth0, etc. In this guide, we are going to use Keycloak which is an open-source Identity and Access Management solution.

Let’s create a Spring Boot application from Spring Initializr by selecting Spring Web, Validation, JDBC API, PostgreSQL Driver, Spring Security, OAuth2 Resource Server, and Testcontainers starters.

We are going to use the testcontainers-keycloak module for testing and running the application locally as well. Also, we are going to use REST Assured for testing the API endpoints. So, once the application is generated, add the following dependencies with test scope:

testImplementation 'com.github.dasniko:testcontainers-keycloak:3.4.0'
testImplementation 'io.rest-assured:rest-assured'

Implement API endpoints

Let’s implement the API endpoints to fetch all products and create a new product. But first create the Product domain class as follows:

package com.testcontainers.products.domain;

import jakarta.validation.constraints.NotEmpty;

public record Product(Long id, @NotEmpty String title, String description) {}

Implement ProductRepository using Spring JdbcClient with PostgreSQL database as follows:

package com.testcontainers.products.domain;

import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;

@Repository
public class ProductRepository {

  private final JdbcClient jdbcClient;

  public ProductRepository(JdbcClient jdbcClient) {
    this.jdbcClient = jdbcClient;
  }

  public List<Product> getAll() {
    return jdbcClient.sql("SELECT * FROM products").query(Product.class).list();
  }

  public Product create(Product product) {
    String sql =
      "INSERT INTO products(title, description) VALUES (:title,:description) RETURNING id";
    KeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcClient
      .sql(sql)
      .param("title", product.title())
      .param("description", product.description())
      .update(keyHolder);
    Long id = keyHolder.getKeyAs(Long.class);
    return new Product(id, product.title(), product.description());
  }
}

Let’s create a file with the name schema.sql under the src/main/resources directory to create the products table.

CREATE TABLE products (
    id bigserial primary key,
    title varchar not null,
    description text
);

To enable database schema initialization, add the following property in src/main/resources/application.properties file.

spring.sql.init.mode=always

For real-world applications, it is recommended to use database migration tools like FlywayDb or Liquibase.

Now, let’s implement ProductController with the API handlers as follows:

package com.testcontainers.products.api;

import com.testcontainers.products.domain.Product;
import com.testcontainers.products.domain.ProductRepository;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/products")
class ProductController {

  private final ProductRepository productRepository;

  ProductController(ProductRepository productRepository) {
    this.productRepository = productRepository;
  }

  @GetMapping
  List<Product> getAll() {
    return productRepository.getAll();
  }

  @PostMapping
  @ResponseStatus(HttpStatus.CREATED)
  Product createProduct(@RequestBody @Valid Product product) {
    return productRepository.create(product);
  }
}

Configure OAuth Security

We are going to protect the Resource Server API endpoints using OAuth 2 JWT Token-based authentication using Spring Security.

Create SecurityConfig class with the following content:

package com.testcontainers.products.config;

import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.configurers.CorsConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
class SecurityConfig {

  @Bean
  SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(c ->
        c
          .requestMatchers(HttpMethod.GET, "/api/products")
          .permitAll()
          .requestMatchers(HttpMethod.POST, "/api/products")
          .authenticated()
          .anyRequest()
          .authenticated()
      )
      .sessionManagement(c ->
        c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
      )
      .cors(CorsConfigurer::disable)
      .csrf(CsrfConfigurer::disable)
      .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
    return http.build();
  }
}

In this configuration class, we have configured the following:

  • Enabled access to GET /api/products endpoint for unauthorized users as well.

  • The POST /api/products endpoint is configured to be accessed only by authenticated users.

  • The OAuth 2 Resource Server is protected using JWT token-based authentication with default configuration.

Now let’s assume Keycloak is running on port 9090, and the realm name is keycloaktcdemo then we need to configure the OAuth JWT Token Issuer URL in application.properties as follows:

spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9090/realms/keycloaktcdemo

Now we have everything configured, but we are assuming that Keycloak is already running and the realm keycloaktcdemo is configured. But we should be able to just clone the code repository and run the tests so that all the test infrastructure will be provisioned automatically.

To make the tests "self-contained", we are going to export the realm configuration, and then use the Testcontainers Keycloak module to automatically start a Keycloak instance and run the tests against it.

Export Keycloak Realm Configuration

We are going to do a one-time setup of starting a Keycloak instance using Docker, configure the realm, and then export the real configuration as a JSON file.

Start the Keycloak server using Docker as follows:

$ docker run -p 9090:8080 \
    -e KEYCLOAK_ADMIN=admin \
    -e KEYCLOAK_ADMIN_PASSWORD=admin \
    quay.io/keycloak/keycloak:25 start-dev

Now you can go to http://localhost:9090 and login into Admin Console using the credentials admin/admin. After logging into the Admin Console, setup realm and product-service client as follows:

  • In the top-left corner, there is a realm drop-down, which provides the option to create a new realm. Create a new realm with the name keycloaktcdemo.

  • Under the keycloaktcdemo realm, create a new client with by providing the following details:

  • Client ID: product-service

  • Client Authentication: On

  • Authentication flow: select only Service accounts roles

  • Now under the Client details screen, go to the Credentials tab and copy the Client secret value.

We have registered the product-service as a client and enabled Client Credentials flow. The other systems can get an Access Token using Client ID and Client Secret.

Now export the keycloaktcdemo realm using the following commands:

$ docker ps
# copy the keycloak container id

# ssh into keycloak container
$ docker exec -it <container-id> /bin/bash

# export the realm configuration
$ /opt/keycloak/bin/kc.sh export --dir /opt/keycloak/data/import --realm keycloaktcdemo

# exit from the container
$ exit

# copy the exported realm configuration to local machine
$ docker cp <container-id>:/opt/keycloak/data/import/keycloaktcdemo-realm.json ~/Downloads/keycloaktcdemo-realm.json

Copy the keycloaktcdemo-realm.json file into src/test/resources folder.

Testing the API endpoints

Spring Boot 3.1.0 introduced out-of-the-box support for Testcontainers which not only simplified testing, but we can use Testcontainers for local development as well. To learn more, please read Spring Boot Application Testing and Development with Testcontainers.

As of Spring Boot 3.2.0, ServiceConnection support is not available for Keycloak. But there is support for Contributing Dynamic Properties at Development Time. So, we can configure KeycloakContainer as a bean and register the JWT Issuer URI property using DynamicPropertyRegistry.

Create ContainersConfig class under src/test/java with the following content:

package com.testcontainers.products;

import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.testcontainers.containers.PostgreSQLContainer;

@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfig {

  static String POSTGRES_IMAGE = "postgres:16-alpine";
  static String KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:25.0";
  static String realmImportFile = "/keycloaktcdemo-realm.json";
  static String realmName = "keycloaktcdemo";

  @Bean
  @ServiceConnection
  PostgreSQLContainer<?> postgres() {
    return new PostgreSQLContainer<>(POSTGRES_IMAGE);
  }

  @Bean
  KeycloakContainer keycloak(DynamicPropertyRegistry registry) {
    var keycloak = new KeycloakContainer(KEYCLOAK_IMAGE)
      .withRealmImportFile(realmImportFile);
    registry.add(
      "spring.security.oauth2.resourceserver.jwt.issuer-uri",
      () -> keycloak.getAuthServerUrl() + "/realms/" + realmName
    );
    return keycloak;
  }
}

We registered a bean of type PostgreSQLContainer and also added @ServiceConnection annotation which will start a PostgreSQL container and automatically register the DataSource properties.

Next, we are registering a bean of type KeycloakContainer using the Docker image quay.io/keycloak/keycloak:25 and importing the realm configuration file. Then we are registering the dynamic JWT Issuer URI using DynamicPropertyRegistry by fetching the AuthServerUrl from the Keycloak container instance.

Now create ProductControllerTests class for testing the API endpoints as follows:

package com.testcontainers.products.api;

import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.when;
import static java.util.Collections.singletonList;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.testcontainers.products.ContainersConfig;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import(ContainersConfig.class)
class ProductControllerTests {

  static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";
  static final String CLIENT_ID = "product-service";
  static final String CLIENT_SECRET = "jTJJqdzeCSt3DmypfHZa42vX8U9rQKZ9";

  @LocalServerPort
  private int port;

  @Autowired
  OAuth2ResourceServerProperties oAuth2ResourceServerProperties;

  @BeforeEach
  void setup() {
    RestAssured.port = port;
  }

  @Test
  void shouldGetProductsWithoutAuthToken() {
    when().get("/api/products").then().statusCode(200);
  }

  @Test
  void shouldGetUnauthorizedWhenCreateProductWithoutAuthToken() {
    given()
      .contentType("application/json")
      .body(
        """
            {
                "title": "New Product",
                "description": "Brand New Product"
            }
        """
      )
      .when()
      .post("/api/products")
      .then()
      .statusCode(401);
  }

  @Test
  void shouldCreateProductWithAuthToken() {
    String token = getToken();

    given()
      .header("Authorization", "Bearer " + token)
      .contentType("application/json")
      .body(
        """
            {
                "title": "New Product",
                "description": "Brand New Product"
            }
        """
      )
      .when()
      .post("/api/products")
      .then()
      .statusCode(201);
  }

  private String getToken() {
    RestTemplate restTemplate = new RestTemplate();
    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
    map.put("grant_type", singletonList(GRANT_TYPE_CLIENT_CREDENTIALS));
    map.put("client_id", singletonList(CLIENT_ID));
    map.put("client_secret", singletonList(CLIENT_SECRET));

    String authServerUrl =
      oAuth2ResourceServerProperties.getJwt().getIssuerUri() +
      "/protocol/openid-connect/token";

    var request = new HttpEntity<>(map, httpHeaders);
    KeyCloakToken token = restTemplate.postForObject(
      authServerUrl,
      request,
      KeyCloakToken.class
    );

    assert token != null;
    return token.accessToken();
  }

  record KeyCloakToken(@JsonProperty("access_token") String accessToken) {}
}

Let’s understand what is going on in this test class:

  • We have the shouldGetProductsWithoutAuthToken() test which invokes the GET /api/products endpoint without adding Authentication header. As this API endpoint is configured to be accessible without any authentication, we should be able to get the response successfully.

  • Next, we have shouldGetUnauthorizedWhenCreateProductWithoutAuthToken() test in which we are invoking the secured POST /api/products endpoint without Authorization header and asserting the response status code to be 401 i.e, Unauthorized.

  • Finally, we have shouldCreateProductWithAuthToken() test in which we first got the access_token using Client Credentials flow. We have added the token as a Bearer token in the Authorization header while invoking POST /api/products endpoint and asserting the response status code to be 201 i.e, Created.

Run tests

# If you are using Maven
./mvnw test

# If you are using Gradle
./gradlew test

You should see the Keycloak Docker container is started with the realm settings imported and the tests should PASS. You can also notice that after the tests are executed, the containers are stopped and removed automatically.

Local Development

As mentioned earlier, Spring Boot’s Testcontainers support can be used for local development as well. We can reuse the ContainersConfig test configuration class and create TestApplication class under src/test/java as follows:

package com.testcontainers.products;

import org.springframework.boot.SpringApplication;

public class TestApplication {

  public static void main(String[] args) {
    SpringApplication
      .from(Application::main)
      .with(ContainersConfig.class)
      .run(args);
  }
}

During the development, instead of running the Application.java under src/main/java, we can run TestApplication.java under src/test/java which automatically starts the containers defined in ContainersConfig class and configures the application to use the dynamically registered properties.

Now you can run locally simply by running the TestApplication.java from your IDE without having to manually install and configure the dependent services like PostgreSQL and Keycloak.

Summary

The Testcontainers Keycloak module enables developing and testing applications using Keycloak without using mocks. This will bring more confidence in our tests as we are using a real Keycloak server that resembles the production setup.

To learn more about Testcontainers visit http://testcontainers.com