POSTED ON 18 NOV 2021
READING TIME: 9 MINUTES
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.
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:
This diagram shows the relationships between the individual components:
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.
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.
Let’s start with the dependencies. If we look inside build.gradle, we will find a few crucial dependencies:
If we look at the application properties, we will find two especially interesting sections:
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:
As mentioned earlier, AGW doesn’t authenticate requests, so we permit all of them.
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:
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:
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.
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:
http
…
.oauth2ResourceServer()
.jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter)
where `jwtAuthenticationConverter` is responsible for converting token claims to `Collection
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.
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.
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.