Microservices with Spring Cloud Gateway, OAuth2 and Keycloak
This article will teach you how to use Keycloak to enable OAuth2 for Spring Cloud Gateway and Spring Boot microservices. We will extend the topics described in my previous article and analyze some of the latest features provided within the Spring Security project.
Our architecture consists of two Spring Boot microservices, an API gateway built on top of Spring Cloud Gateway, and a Keycloak authorization server. Spring Cloud Gateway acts here as an OAuth2 Client and OAuth2 Resource Server. For any incoming request, it verifies an access token before forwarding traffic to the downstream services. It initializes an authorization code flow procedure with Keycloak for any unauthenticated request. Our scenario needs to include the communication between internal microservices. They are both hidden behind the API gateway. The caller
app invokes an endpoint exposed by the callme
app. The HTTP client used in that communication has to use the access token sent by the gateway.
Source Code
If you would like to try this exercise yourself, you may always take a look at my source code. In order to do that, you need to clone my GitHub repository. Then switch to the oauth
directory. You will find two Spring Boot microservices there: callme
and caller
. Of course, there is the gateway
app built on top of Spring Cloud Gateway. After that, you should just follow my instructions. Let’s begin.
Run and Configure Keycloak
We are running Keycloak as a Docker container. By default, Keycloak exposes API and a web console on the port 8080
. We also need to set an admin username and password with environment variables. Here’s the command used to run the Keycloak container:
$ docker run -d --name keycloak -p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:23.0.7 start-dev
ShellSessionOnce the container starts, we can go to the UI admin console available under the http://localhost:8080/admin
address. We will create a new realm. The name is that realm is demo
. Instead of creating the required things manually, we can import the JSON resource file that contains the whole configuration of the realm. You can find such a resource file in my GitHub repository here: oauth/gateway/src/test/resources/realm-export.json
. However, in the next parts of that section, we will use the Keycloak dashboard to create objects step by step. In case you import the configuration from the JSON resource file, you can just skip to the next section.
Then, we need to add a single OpenID Connect client to the demo
realm. The name of our client is spring-with-test-scope
. We should enable client authentication and put the right address in the “Valid redirect URIs” field (it can be the wildcard for testing purposes).
We need to save the name of the client and its secret. Those two settings have to be set on the application side.
Then, let’s create a new client scope with the TEST
name.
Then, we have to add the TEST to the spring-with-test-scope
client scopes.
We also need to create a user to authenticate against Keycloak. The name of our user is spring
. In order to set the password, we need to switch to the “Credentials” tab. For my user, I choose the Spring_123
password.
Once we finish with the configuration, we can export it to the JSON file (the same file we can use when creating a new realm). Such a file will be useful later, for building automated tests with Testcontainers.
Unfortunately, Keycloak doesn’t export realm users to the file. Therefore, we need to add the following JSON to the users
section in the exported file.
{
"username": "spring",
"email": "piotr.minkowski@gmail.com",
"firstName": "Piotr",
"lastName": "Minkowski",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "Spring_123"
}
],
"realmRoles": [
"default-roles-demo",
"USER"
]
}
JSONCreate Spring Cloud Gateway with OAuth2 Support and Keycloak
As I mentioned before, our gateway app will act as an OAuth2 Client and OAuth2 Resource Server. In that case, we include both the Spring Boot Auth2 Client Starter and the spring-security-oauth2-resource-server
dependency. We also need to include the spring-security-oauth2-jose
to decode JWT tokens automatically. Of course, we need to include the Spring Cloud Gateway Starter. Finally, we add dependencies for automated testing with JUnit. We will use Testcontainers to run the Keycloak container during the JUnit test. It can be achieved with the com.github.dasniko:testcontainers-keycloak
dependency.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>3.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.6</version>
<scope>test</scope>
</dependency>
XMLLet’s begin with the Spring Security configuration. First, we need to annotate the Configuration
bean with @EnableWebFluxSecurity
. That’s because Spring Cloud Gateway uses the reactive version of the Spring web module. The oauth2Login()
method is responsible for redirecting an unauthenticated request to the Keycloak login page. On the other hand, the oauth2ResourceServer()
method verifies an access token before forwarding traffic to the downstream services.
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.authorizeExchange(auth -> auth.anyExchange().authenticated())
.oauth2Login(withDefaults())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
http.csrf(ServerHttpSecurity.CsrfSpec::disable);
return http.build();
}
}
JavaThat’s not all. We also need to provide several configuration settings with the spring.security.oauth2
prefix. The Spring OAuth2 Resource Server module will use the Keycloak JWKS endpoint to verify incoming JWT tokens. In the Spring OAuth2 Client section, we need to provide the address of the Keycloak issuer realm. Of course, we also need to provide the Keycloak client credentials, choose the authorization grant type and scope.
spring.security.oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:8080/realms/demo/protocol/openid-connect/certs
client:
provider:
keycloak:
issuer-uri: http://localhost:8080/realms/demo
registration:
spring-with-test-scope:
provider: keycloak
client-id: spring-with-test-scope
client-secret: IWLSnakHG8aNTWNaWuSj0a11UY4lzxd9
authorization-grant-type: authorization_code
scope: openid
YAMLThe gateway exposes a single HTTP endpoint by itself. It uses OAuth2AuthorizedClient
bean to return the current JWT access token.
@SpringBootApplication
@RestController
public class GatewayApplication {
private static final Logger LOGGER = LoggerFactory
.getLogger(GatewayApplication.class);
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@GetMapping(value = "/token")
public Mono<String> getHome(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient) {
return Mono.just(authorizedClient.getAccessToken().getTokenValue());
}
}
JavaThat’s all about OAuth2 configuration in that section. We also need to configure routing on the gateway in the Spring application.yml
file. Spring Cloud Gateway can forward OAuth2 access tokens downstream to the services it is proxying using the TokenRelay
GatewayFilter
. It is possible to set it as a default filter for all incoming requests. Our gateway forwards traffic to both our callme
and caller
microservices. I’m not using any service discovery in that scenario. By default, the callme
app listens on the 8040
port, while the caller
app on the 8020
port.
spring:
application:
name: gateway
cloud:
gateway:
default-filters:
- TokenRelay=
routes:
- id: callme-service
uri: http://localhost:8040
predicates:
- Path=/callme/**
- id: caller-service
uri: http://localhost:8020
predicates:
- Path=/caller/**
YAMLVerify Tokens in Microservices with OAuth2 Resource Server
The list of dependencies for the callme
and caller
is pretty similar. They are exposing HTTP endpoints using the Spring Web module. Since the caller
app uses the WebClient
bean we also need to include the Spring WebFlux dependency. Once again, we need to include the Spring OAuth2 Resource Server module and the spring-security-oauth2-jose
dependency for decoding JWT tokens.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
</dependency>
XMLHere’s the configuration of the app security. This time we need to use the @EnableWebSecurity
annotation since we have a Spring Web module. The oauth2ResourceServer()
method verifies an access token with the Keyclock JWKS endpoint.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
JavaHere’s the OAuth2 Resource Server configuration for Keycloak in the Spring application.yml
file:
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:8080/realms/demo/protocol/openid-connect/certs
YAMLLet’s take a look at the implementation of the REST controller class. It is a single ping
method. That method may be accessed only by the client with the TEST
scope. It returns a list of assigned scopes taken from the Authentication
bean.
@RestController
@RequestMapping("/callme")
public class CallmeController {
@PreAuthorize("hasAuthority('SCOPE_TEST')")
@GetMapping("/ping")
public String ping() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
return "Scopes: " + authentication.getAuthorities();
}
}
JavaThis method can be invoked directly by the external client through the API gateway. However, also the caller
app calls that endpoint inside its own “ping” endpoint implementation.
@RestController
@RequestMapping("/caller")
public class CallerController {
private WebClient webClient;
public CallerController(WebClient webClient) {
this.webClient = webClient;
}
@PreAuthorize("hasAuthority('SCOPE_TEST')")
@GetMapping("/ping")
public String ping() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String scopes = webClient
.get()
.uri("http://localhost:8040/callme/ping")
.retrieve()
.bodyToMono(String.class)
.block();
return "Callme scopes: " + scopes;
}
}
JavaIf the WebClient calls the endpoint exposed by the second microservice, it also has to propagate the bearer token. We can easily achieve it with the ServletBearerExchangeFilterFunction
as shown below. Thanks to that Spring Security will look up the current Authentication
and extract the AbstractOAuth2Token
credential. Then, it will propagate that token in the Authorization
header automatically.
@SpringBootApplication
public class CallerApplication {
public static void main(String[] args) {
SpringApplication.run(CallerApplication.class, args);
}
@Bean
public WebClient webClient() {
return WebClient.builder()
.filter(new ServletBearerExchangeFilterFunction())
.build();
}
}
JavaTesting with Running Applications
We can run all three Spring Boot apps using the same Maven command. Let’s begin with the gateway
app:
$ cd oauth/gateway
$ mvn spring-boot:run
ShellSessionOnce we run the first app, we can check out the logs if everything works fine. Here are the logs generated by the gateway
app. As you see, it listens on the 8060
port.
After that, we can run e.g. the caller
app.
$ cd oauth/caller
$ mvn spring-boot:run
ShellSessionIt listens on the 8020
port.
Of course, the order of starting apps doesn’t matter. As the last one, we can run the callme
app.
$ cd oauth/callme
$ mvn spring-boot:run
ShellSessionNow, let’s call the caller
app endpoint through the gateway. In that case, we need to go to the http://localhost:8060/caller/ping
URL. The gateway app will redirect us to the Keycloak login page. We need to sign in there with the spring
user and Spring_123
password.
After we sign in, everything happens automatically. Spring Cloud Gateway obtains the access token from Keycloak and then sends it to the downstream service. Once the caller
app receives the request, it invokes the callme
app using the WebClient
instance. Here’s the result:
We can easily get the access token using the endpoint GET /token
exposed by the gateway
app.
Now, we can perform a similar call as before, but with the curl
command. We need to copy the token string and put it inside the Authorization
header as a bearer token.
$ curl http://localhost:8060/callme/ping \
-H "Authorization: Bearer <TOKEN>" -v
ShellSessionHere’s my result:
Now, let’s do a similar thing, but in a fully automated way with JUnit and Testcontainers.
Spring OAuth2 with Keycloak Testcontainers
We need to switch to the gateway
module once again. We will implement tests that run the API gateway app, connect it to the Keycloak instance, and route the authorized traffic in the target endpoint. Here’s the @RestController
in the src/test/java
directory that simulates the callme
app endpoint:
@RestController
@RequestMapping("/callme")
public class CallmeController {
@PreAuthorize("hasAuthority('SCOPE_TEST')")
@GetMapping("/ping")
public String ping() {
return "Hello!";
}
}
JavaHere’s the required configuration to run the tests. We are starting the gateway app on the 8060
port and using the WebTestClient
instance for calling it. In order to automatically configure Keycloak we will import the demo
realm configuration stored in the realm-export.json
. Since Testcontainers use random port numbers we need to override some Spring OAuth2 configuration settings. We also override the Spring Cloud Gateway route, to forward the traffic to our test implementation of the callme
app controller instead of the real service. That’s all. We can proceed to the tests implementation.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class GatewayApplicationTests {
static String accessToken;
@Autowired
WebTestClient webTestClient;
@Container
static KeycloakContainer keycloak = new KeycloakContainer()
.withRealmImportFile("realm-export.json")
.withExposedPorts(8080);
@DynamicPropertySource
static void registerResourceServerIssuerProperty(DynamicPropertyRegistry registry) {
registry.add("spring.security.oauth2.client.provider.keycloak.issuer-uri",
() -> keycloak.getAuthServerUrl() + "/realms/demo");
registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri",
() -> keycloak.getAuthServerUrl() + "/realms/demo/protocol/openid-connect/certs");
registry.add("spring.cloud.gateway.routes[0].uri",
() -> "http://localhost:8060");
registry.add("spring.cloud.gateway.routes[0].id", () -> "callme-service");
registry.add("spring.cloud.gateway.routes[0].predicates[0]", () -> "Path=/callme/**");
}
// TEST IMPLEMENTATION ...
}
JavaHere’s our first test. Since it doesn’t contain any token it should be redirected into the Keycloak authorization mechanism.
@Test
@Order(1)
void shouldBeRedirectedToLoginPage() {
webTestClient.get().uri("/callme/ping")
.exchange()
.expectStatus().is3xxRedirection();
}
JavaIn the second test, we use the WebClient
instance to interact with the Keycloak container. We need to authenticate against Kecloak with the spring
user and the spring-with-test-scope
client. Keycloak will generate and return an access token. We will save its value for the next test.
@Test
@Order(2)
void shouldObtainAccessToken() throws URISyntaxException {
URI authorizationURI = new URIBuilder(keycloak.getAuthServerUrl() + "/realms/demo/protocol/openid-connect/token").build();
WebClient webclient = WebClient.builder().build();
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.put("grant_type", Collections.singletonList("password"));
formData.put("client_id", Collections.singletonList("spring-with-test-scope"));
formData.put("username", Collections.singletonList("spring"));
formData.put("password", Collections.singletonList("Spring_123"));
String result = webclient.post()
.uri(authorizationURI)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData))
.retrieve()
.bodyToMono(String.class)
.block();
JacksonJsonParser jsonParser = new JacksonJsonParser();
accessToken = jsonParser.parseMap(result)
.get("access_token")
.toString();
assertNotNull(accessToken);
}
JavaFinally, we run a similar test as in the first step. However, this time, we provide an access token inside the Authorization
header. The expected response is 200 OK
and the “Hello!” payload, which is returned by the test instance of the CallmeController
bean.
@Test
@Order(3)
void shouldReturnToken() {
webTestClient.get().uri("/callme/ping")
.header("Authorization", "Bearer " + accessToken)
.exchange()
.expectStatus().is2xxSuccessful()
.expectBody(String.class).isEqualTo("Hello!");
}
JavaLet’s run all the tests locally. As you see, they are all successfully finished.
Final Thoughts
After publishing my previous article about Spring Cloud Gateway and Keycloak I received a lot of comments and questions with a request for some clarifications. I hope that this article answers some of them. We focused more on automation and service-to-service communication than just on the OAuth2 support in the Spring Cloud Gateway. We considered a case where a gateway acts as the OAuth2 client and resource server at the same time. Finally, we used Testcontainers to verify our scenario with Spring Cloud Gateway and Keycloak.
18 COMMENTS