Keycloak Testcontainers

In this bonus lab we'll see how we can leverage Testcontainers and Keycloak Testcontainer to create a client-side end2end test for our OAuth 2.0 and OpenID Connect 1.0 compliant Resource Server.

Lab Contents

Learning Targets

In this lab we will add end-to-end tests for our OAuth2/OIDC compliant resource server.

We will use Keycloak Testcontainer as identity provider for this. So the tests will run using Keycloak as real identity provider.

In this bonus lab you will learn how to:

  1. How to write automated client-side end2end tests using RestAssured

  2. How to get a real JWT from Keycloak in the tests using Testcontainers and Keycloak Testcontainer.

Folder Contents

You find 2 applications in the folder bonus-labs/keycloak-test-containers:

  • library-server-initial: This is the application we will use as starting point for this lab

  • library-server-complete: This application is the completed reference for this lab

Start the Lab

In this lab we will implement:

  • A unit test to verify the LibraryUserJwtAuthenticationConverter.

  • An integration test to verify correct authentication & authorization for the books API using JWT

Please start this lab with the project located in bonus-labs/keycloak-test-containers/library-server-initial.

Step 1: Add required dependencies

First we need to add the required dependencies

testImplementation('com.github.dasniko:testcontainers-keycloak:1.6.0')
testImplementation('org.testcontainers:junit-jupiter:1.15.2')

build.gradle

Step 2: Extend the End2end Integration Test with Testcontainers

Now let's start with building the test. Open the existing test class com.example.library.server.api.BookApiEnd2EndTest and add the missing parts.

In the test class you already will find two test cases for the well-known books api:

  • verifyGetBooks(): This tests the happy path accessing the list of books with a valid JWT retrieved from Keycloak

  • verifyGetBooksFail(): This tests the error path getting http status 401 (unauthenticated) when trying the same without a token

First we'll add all the required parts for Testcontainers and Keycloak Testcontainer.

  • The annotation @Testcontainers scans and configures all testcontainers marked with the other annotation @Container.

  • Then we create an instance of KeycloakContainer that loads the same realm configuration from keycloak_realm_workshop.json as used in the real Keycloak instance.

  • In the setup() operation we retrieve the required token endpoint url by retrieving the base url via keycloak.getAuthServerUrl()

package com.example.library.server.api;

//...

import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

//...

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext
@DisplayName("Verify book api")
@Tag("end2end")
class BookApiEnd2EndTest {

  @Container
  private static final KeycloakContainer keycloak =
      new KeycloakContainer()
          .withRealmImportFile("keycloak_realm_workshop.json")
          .withEnv("DB_VENDOR", "h2");

  @LocalServerPort private int port;

  private String authServerUrl;

  @BeforeEach
  void setup() {
    authServerUrl = keycloak.getAuthServerUrl() + "/realms/workshop/protocol/openid-connect/token";
    RestAssured.baseURI = "http://localhost";
    RestAssured.port = port;
  }

  @Test
  @DisplayName("get list of books")
  void verifyGetBooks() {

    String token = getToken();

    given()
        .header("Authorization", "Bearer " + token)
        .when()
        .get("/library-server/books")
        .then()
        .statusCode(200);
  }
  //...
}

With this code in place you should already be able to run the tests. You will notice that running the tests will last some time due to starting the Keycloak docker container first. After the container started successfully the test cases will run. Here you will recognize that the test for the happy path is still failing. This is caused by the wrong JWT issuer claim. The Keycloak testcontainer runs at a different port than the Keycloak from previous labs. The issuer claim has changed as well.

No worries, we will change this in the next section.

Step 3: Reconfigure the Issuer Claim for JWT

Open again the same test class and change the setup() operation as shown here:

import dasniko.testcontainers.keycloak.KeycloakContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext
@DisplayName("Verify book api")
@Tag("end2end")
class BookApiEnd2EndTest {

  //...
  @SuppressWarnings("unused")
  @DynamicPropertySource
  static void jwtValidationProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
        () -> keycloak.getAuthServerUrl() + "/realms/workshop");
        registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
        () -> keycloak.getAuthServerUrl() + "/realms/workshop/protocol/openid-connect/certs");
  }
  //...
}

To change the issuer and jwks uri, we dynamically override the oauth2 resource server configuration using DynamicPropertyRegistry instance, and the @DynamicPropertySource spring test support.

Now we can just set the issuer to the configured value of the Keycloak testcontainer.

Now the testing class should be complete and look like the following one:

package com.example.library.server.api;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import dasniko.testcontainers.keycloak.KeycloakContainer;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.Collections;

import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.when;

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DirtiesContext
@DisplayName("Verify book api")
@Tag("end2end")
class BookApiEnd2EndTest {

    @Container
    private static final KeycloakContainer keycloak =
            new KeycloakContainer()
                    .withRealmImportFile("keycloak_realm_workshop.json")
                    .withEnv("DB_VENDOR", "h2");

    @LocalServerPort private int port;

    private String authServerUrl;

    @BeforeEach
    void setup() {
        this.authServerUrl = keycloak.getAuthServerUrl() + "/realms/workshop/protocol/openid-connect/token";
        RestAssured.baseURI = "http://localhost";
        RestAssured.port = port;
    }

    @SuppressWarnings("unused")
    @DynamicPropertySource
    static void jwtValidationProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri",
                () -> keycloak.getAuthServerUrl() + "/realms/workshop");
        registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
                () -> keycloak.getAuthServerUrl() + "/realms/workshop/protocol/openid-connect/certs");
    }

    @Test
    @DisplayName("get list of books")
    void verifyGetBooks() {

        String token = getToken();

        given()
                .header("Authorization", "Bearer " + token)
                .when()
                .get("/library-server/books")
                .then()
                .statusCode(200);
    }

    @Test
    @DisplayName("get list of books fails without token")
    void verifyGetBooksFail() {
        when().get("/library-server/books").then().statusCode(401);
    }

    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", Collections.singletonList("password"));
        map.put("client_id", Collections.singletonList("library-client"));
        map.put("client_secret", Collections.singletonList("9584640c-3804-4dcd-997b-93593cfb9ea7"));
        map.put("username", Collections.singletonList("bwayne"));
        map.put("password", Collections.singletonList("wayne"));
        KeyCloakToken token =
                restTemplate.postForObject(
                        authServerUrl, new HttpEntity<>(map, httpHeaders), KeyCloakToken.class);

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

    private static class KeyCloakToken {

        private final String accessToken;

        @JsonCreator
        KeyCloakToken(@JsonProperty("access_token") final String accessToken) {
            this.accessToken = accessToken;
        }

        public String getAccessToken() {
            return accessToken;
        }
    }
}

Step 4: Running the Tests

Now you can run the tests and all tests should run fine and report a green status. Please also notice that the test is tagged with @Tag("end2end"). This way you can for example exclude such long-running tests from the regular build and instead only run these as part of a nightly build.

Actually the gradle build excludes this test here as well using the following additional snippet in the build.gradle file:

test {
    useJUnitPlatform {
        excludeTags 'end2end'
    }
}

This ends this bonus lab. If you like the approach with the Testcontainers then you may look for other supported testcontainers like for example databases (this is also a great possibility to test against the real database instead of simulating this using a H2 in-memory database).

Last updated