Reading Time: 7 minutes

OAuth2 Authentication with API Gateway in a Distributed Environment

by Wojciech Znaczko, posted 18/11/2021

 

Recently I was involved in a project in which the application was structured as a group of microservices. Users were interacting with the system through a web portal. The challenge we were facing was to prepare a tailored solution that would allow users to easily authenticate across the multiple components of the system.

A lot has been written about distributed systems, as well as OAuth2, API gateways and Kubernetes separately. But when I was looking for ways to combine these technologies to build an end-to-end solution, I found that they are mostly described in isolation from other parts of the system. That’s why I decided to do a small experiment and find an elegant way to authenticate within this specific infrastructure.

If you ever get a similar challenge, I hope that this will show you an example of integrating systems that don’t necessarily show you an easy way to do it.

Requirements

Before jumping straight to the project, let’s take a look at the specifics of the environment I was working with. You’ll need these if you try to reproduce the solution:

  1. Kubernetes as an orchestration platform for the distributed environment.
  2. Users managed by an identity provider (IDP) following OAuth2/OIDC specification (e.g. Keycloak, Azure Active Directory etc.).
  3. Backend services authenticate/authorize users using stateless access token added to the request as Authorization header.
  4. AGW is the client application in OAuth2 terminology. In case the frontend needs data about authenticated users, it gets it from AGW.
  5. Session timeout – access to the system is denied after a given period of time since the last request.

Solution overview

This diagram shows the relationships between the individual components:

Kubernetes OAuth2

Let’s follow the request path (red arrows). Once a client browser reaches the K8s cluster, it enters the system through Kubernetes’ specialized load balancer, the Ingress controller, which in our case is implemented with Nginx. As we can see, all traffic goes through AGW. A natural consequence is that all routing happens inside AGW.

We can see two streams of calls to IDP (the black arrows on the diagram). The first, coming out of AGW, is responsible for driving the authentication process which consists of acquiring the token and refreshing it within the requests so it doesn’t timeout. Additionally, AGW adds a login page URL (as a header) to unauthenticated requests to allow the client to start authentication. That’s the only small bit of FE participation in the authentication process.

Why couldn’t AGW just send a redirect itself and mention the redirect location in a header? Because we are talking here about API calls only (which in a browser are XMLHttpRequest, not browser top-level navigation), we return 401 for unauthenticated requests, hence the browser won’t do top-level redirection based on these calls.

The second stream coming out from the microservices is fetching public keys for token validation. We will take a closer look at that later on a sequence diagram.

It is worth mentioning that AGW doesn’t authenticate requests. Someone could ask “why”, as authentication is very often associated with API gateways. Well, based on the above we can say that AGW drives the authentication, knows how to authenticate users, can acquire a token for a user (after user consent is given), but doesn’t authenticate the requests going through it. The reason behind this approach is that some of the services are protected, and some are public. Even in a single service, sometimes only a couple of endpoints could be protected instead of each one of them. That’s why I left the authentication/authorization of requests to the specific services. The implementation of course doesn’t prevent us from doing authentication also in AGW. It’s just a matter of choice.

AGW (as we will see in the next section) is backed with spring-cloud-gateway and spring-security-oauth2-client. In this setup, the user session is kept inside the AGW. On the other hand, AGW is a regular Kubernetes deployment, which means that it can be evicted, restarted, scaled up and down at any time. That’s why we need a distributed session that persists throughout the service lifecycle. I decided to use a Redis backed WebSession.

Implementation details

In order to keep this clear and easy to read, I will focus only on the most important building blocks. If you’d like to see the full source code, you can find it on Github.

The project consists of three components; two Java modules and a client portal. The first Java module named `agw` is the AGW component, and the second called `customers` is a BE microservice. Under the `portal` directory we will find a React app that serves as a FE client.

AGW

Let’s start with the dependencies. If we look inside build.gradle, we will find a few crucial dependencies:

  • `spring-boot-starter-oauth2-client` provides all the security mechanisms around OAuth2
  • `spring-cloud-starter-gateway` is the spring implementation of gateway functionality, e.g. routing, enhancing the requests via filters etc.
  • `spring-boot-starter-data-redis` together with `spring-session-data-redis` provide the distributed session functionality backed with Redis

If we look at the application properties, we will find two especially interesting sections:

  • `spring.security.oauth2.client` – the configuration of OAuth2 client that the spring security will consume, for development purposes I used Keycloak as IDP (check README file in the source code for more reference)
  • `spring.cloud.gateway` configures static routing rules and default filters. The first route exposes jwks-uri as an internal Kubernetes endpoint and the next two are rules for routing traffic to the portal and the customers service.

Now, let’s go through the code. We shall start from spring security configuration, as most of the functionality of this component relies on it.

@Bean
  public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
    return http
        .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
        .authorizeExchange(ae -> ae		//1
        .anyExchange()
        .permitAll()
      )
      .oauth2Login(l -> l
        .authorizedClientRepository(authorizedClientRepository())	//2
        .authenticationSuccessHandler(authenticationSuccessHandler)	//3
        .authenticationFailureHandler(authenticationFailureHandler)	//4
      )    
      .logout(l -> l
        .logoutSuccessHandler(logoutSuccessHandler)			// 5
      )
      .csrf()
      .disable()
      .build();
  }

Let’s look at what this does, other than some basic configuration:

1) As mentioned earlier, AGW doesn’t authenticate requests, so we permit all of them.

2) We need to make spring security work with a distributed session. Almost all classes that store some data are leveraging WebSession. Only the ServerOAuth2AuthorizedClientRepository needs to be defined to use the WebSession backed solution.

3), 4), 5) Minor behaviours customisation in case of login success/failure and logout, mainly redirecting to the right place in the app after these actions complete.

We also add 2 filters in the filters package:

  • `Global401Filter` – when downstream service returns 401 it adds login page URL in `X-auth-entrypoint` header to allow the caller client to navigate the user to it.
  • `SessionFilter` – spring maintains the session itself, but it doesn’t expose session expiration to the requests, that’s why we calculate the expiration and add it as a cookie to every request so that the client app can react to it to improve user experience.

The controller package exposes the `/whoami` endpoint that a client app can call to figure out whether the user is authenticated or not. If the user is authenticated, the endpoint returns status 200 with the user details payload. Otherwise, 401 status is returned with the login page URL in a header.

At this point, let’s discuss the idea behind the above setup. spring-security will handle everything related to authentication, such as:

  • redirecting to the IDP login page after user reaches `/oauth2/authorization/<client-registration-name>` where – in our case – the registration name is `iam`,
  • performing the OAuth2 authorization code exchange and automatically refreshing the access token,
  • storing users authentication details data in the session,
  • clearing the session on logout.

The session is persisted between requests using a SESSION cookie containing the session id. No other sensitive data (like OAuth token) is exposed to the frontend.

One of the spring-cloud-gateway functionalities is routing traffic to the underlying services. A TokenRelay filter will also extract the access token stored in the user session and add it to outgoing requests as an `Authorization` header. That allows downstream services to authenticate the request.

If the client app wants to authenticate the user, it can call the `/whoami` endpoint. Based on the result it can perform any operation it wants (e.g. redirecting to the login page to start the authentication process). AGW doesn’t force any client behaviour, it only exposes the necessary data to perform authentication.

The ‘customers’ microservice

As stated in the requirements, BE microservices should do the authentication/authorization. We achieve that by using `org.springframework.boot:spring-boot-starter-oauth2-resource-server`. To make it work, we need two things:

  • In application properties under the `spring.security.oauth2.resourceserver.jwt.jwk-set-uri` key, we define a URL under which the public keys are available,
  • when configuring `HttpSecurity`, we enable the JWT resource server like this:
http
  …
  .oauth2ResourceServer()
  .jwt()
  .jwtAuthenticationConverter(jwtAuthenticationConverter)

where `jwtAuthenticationConverter` is responsible for converting token claims to `Collection<GrantedAuthority>`. We store the roles as a comma-separated list in one of the user claims.

Portal

As mentioned above, the portal isn’t too involved in the authentication process. However, there are a few points that are worth highlighting.

In `src/index.tsx`, before the application starts, we call `/whoami` (endpoint already discussed in the AGW section) and store the result for later usage. Once the user reaches the protected section of the app, which technically means entering an src/AppInternal.tsx component, the component will consume the whoami call result and it will either let the user in or redirect the user to the login page.

To improve the user experience, we store the page URL when the whoami call returns 401. Then, after the user comes back authenticated, we can redirect the user back to the stored URL.

Additionally, the `src/components/session/Session.tsx` component will track the session expiration. It consumes the session expiration stored in the cookie set by the AGW. If the session is about to finish, the user gets notified with a pop-up. When the session expires, the user is logged out.

Sequence diagram

Let’s see what an example user scenario would look like. We assume that the user hasn’t been authenticated yet. The user wants to see the `/customers` page. If we have looked at the portal behaviour for `/customers` we would also notice that for an authenticated user, it will try to fetch data from the `/api/customers` endpoint.

Summary

As we can see, this is not a complicated setup. It’s just a bit of configuration. The spring-cloud-gateway and spring-security-oauth2-client libraries put together cover the claimed requirements almost out of the box. However, to correctly understand the capabilities of these libraries, I had to spend some time going through their source code.

Additionally, due to the fact that spring-cloud-gateway is implemented using Spring WebFlux, it should by definition provide better resource utilisation. I strongly recommend looking at Spring Cloud solutions for any cloud-related topic.