Foto Source: Ron Lach (www.pexels.com)
Option: OpenID & Keycloak
In the previous blog (part 4), we have introduced JSON Web Token to allow more transparent and convenient authentication and access management. In this blog (part 5), we will explore how we can extend the concept of a JWT to achieve even better standardization through oAuth2 / OpenID Connect. This will further relieve the application developers and operations by delegating the generation and management to a 3rd party (Identity and Access Management server).
Overview of the Options & Links to Blogs
In Part 1, we’ve provided the context for the whole blog series. We recommend you read it first because otherwise, you miss out on the context.
You find below an overview of the content of these blog series. Just click on the link to jump directly to the respective blog part:
Blog Part | Implementation Option | Description |
(2/7) | HTTP Query Param | This is the most basic module where the “role” is transferred as a HTTP Query Parameter. The server validates the role programmatically. |
(3/7) | Basic Authentication | A user agent uses Basic Authentication to transfer credentials. |
(4/7) | JWT  | A JSON Web Token (JWT) codifies claims that are granted and can be objectively validated by the receiver. |
This blog | OpenID and Keycloak | For further standardization, OpenID Connect is used as an identity layer. Keycloak acts as an intermediary to issue a JWT token. |
(6/7) | Proxied API Gateway (3Scale) | ServiceB uses a proxied gateway (3Scale) which is responsible for enforcing RBAC. This is useful for legacy applications that can’t be enabled for OIDC. |
(7/7) | Service Mesh  | All services are managed by a Service Mesh. The JWT is created outside and enforced by the Service Mesh. |
What do we want to achieve in this blog part?
In the resume of the previous blog (part 4), we have stated that a JWT yields some improvements to the security and trust, particularly if more than one service is involved.
But it still has some shortcomings:
- The generation of the JWT has to be done manually
- In order to establish trust between the services, the services need to have access to the public keys of their counterparties. If there are multiple services involved, this becomes very tedious to manage
- The process of generating, propagating and validating the JWT is up to the applications. Thus, it is costly to maintain if there are ad-hoc implementations.
- If we like to include different authentication provider (e.g. social login), we would need again implement this integration on a case-by-case basis.
Enter OpenID Connect & Keycloak.
OpenID Connect is an authentication protocol (on top of OAuth 2.0) to standardize processes around tokens. Keycloak is an open-source Identity and Access Management solution that implements – amongst other standards – OpenID Connect. On April, 11, 2023 it has been voted to become a CNCF incubating project.
Luckily, Keycloak uses JWT as the token format. Thus, many of the concepts and structures from the previous post will look familiar.
Architecture Overview
In this blog, we will demonstrate how to embed Keycloak as an intermediary between ServiceA and ServiceB. See below a visualization of the architecture and explanation of the flows:
Before the actual token generation and validation, ServiceA and ServiceB needs to be configured to “understand” OpenID and have a reference to a running Keycloak Server instance (0). This happens through adding a Quarkus extension (“oidc”) and setting some environment variables.
The actual flow happens as follows:
- (1) One of the secured end-points of ServiceA is called
- (2) ServiceA redirects to Keycloak and asks for authentication
- (3) Keycloak performs the authentication (in the most simple form via a Login page) and sends back a JWT token – of course only if the authentication was successful. The content of this JWT token is configured in Keycloak (e.g. audience of the token, roles,…).
(Remark: In most cases, Keycloak refers to other identity stores / authentication provider to retrieve and/or verify credentials, e.g. LDAP servers, social login provider, etc. But this is transparent for the application. In our simplistic case, we will also have the credentials stored in Keycloak.) - (4) ServiceA uses the JWT token to call ServiceB
- (5) ServiceB validates the token. That means, it checks that the token has been issued by Keycloak and also contains the right “claims” to assess the end-point
(Remark: The called service could potentially also delegate the validation of the token to Keycloak. But as JWTs are not opaque, it is possible and more efficient that the service validates tokens without calling Keycloak.)
Prerequisites
We are using the following tools:
Code Base:
You can either:
- continue from the previous blog and delete the JWT-specific settings from the application.properties file:
mp.jwt.verify.publickey.location=publicKey.pem
mp.jwt.verify.issuer=https://example.com/issuer
mp.jwt.verify.audiences=myservices - or clone the code base from here to have a clean start.
Implementation
We will explain step-by-step how you can achieve multi-service RBAC with OpenID Connect and Keycloak. If you are only interested in the end result, you can clone this from git here.
With Quarkus, we can implement most of the logic through very simple commands. Nevertheless, it is important to understand what is happening under the hood and relate back to the description of the flow from the previous chapter.
- We will start by adding the “oidc” extension to the ServiceA and ServiceB project.
- Add the following stanza to the pom-file:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency> - or execute the command:
mvn quarkus:add-extension -Dextensions=oidc
- Add the following stanza to the pom-file:
- In order to simulate the re-routing workflow, we will specify ServiceA as a “Web-App” which means that this service will be used to generate tokens.
quarkus.oidc.application-type=web-app - Moreover, we will specify for ServiceA that we want the “userEP” and “adminEP” to be authenticated. This means that for both end-points the automatic redirect to Keycloak will happen.
quarkus.http.auth.permission.userEP.paths=/serviceA/userEP/*
quarkus.http.auth.permission.userEP.policy=authenticated
quarkus.http.auth.permission.adminEP.paths=/serviceA/adminEP/*
quarkus.http.auth.permission.adminEP.policy=authenticated - Unfortunately, the propagation of the JWT doesn’t work that simple as in the previous posts. The reason is that ServiceA is not called with a token, but obtains the token (through the redirect). Thus, a simple propagation doesn’t work.
For this reason, we need to add a JWT Header factory class to ServiceA that retrieves the token from the injected jwt and adds it to the request header.
- Add a class called “RequestJWTHeaderFactory”:
package org.acme;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory;
import org.jboss.logging.Logger;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.ws.rs.core.MultivaluedHashMap;
import javax.ws.rs.core.MultivaluedMap;
@ApplicationScoped
public class RequestJWTHeaderFactory implements ClientHeadersFactory {
@Inject
JsonWebToken jwt;
@Override
public MultivaluedMap<String, String> update(MultivaluedMap<String, String> incomingHeaders, MultivaluedMap<String, String> clientOutgoingHeaders) {
MultivaluedMap<String, String> result = new MultivaluedHashMap<>();
String token;
token=jwt.getRawToken();
result.add("Authorization", "Bearer "+ token);
return result;
}
}
- Refer to this HeaderFactory in the interface class:
Amend the existing annotation which uses the default HeaderFactory
@RegisterClientHeaders()
to
@RegisterClientHeaders(RequestJWTHeaderFactory.class)
- Add a class called “RequestJWTHeaderFactory”:
- Now, we need to set-up Keycloak. Fortunately, Quarkus comes with a very convenient feature to automatically start a Keycloak instance in a container if certain extensions like “oidc” are added to the project. This feature is called “Keycloak DevServices“. Thus, we don’t even need to add any reference to the Keycloak instance, as Quarkus will discover this automatically.
There are many ways to configure how this Keycloak instance is started (e.g. which container image version, port, importing data,…), but if these are not specified, Quarkus will set reasonable defaults. This makes our life a lot easier and is sufficient for prototyping.
Let’s only specify the port where the Keycloak instance is started in order to distinguish it later on:
quarkus.keycloak.devservices.port=5000 - Let’s start the ServiceA and ServiceB and see what happens.
You should see in the output from both Services that “Dev Services for Keycloak started”
(Please note that Quarkus is so smart to start only the first time a new Keycloak instance. The other service – which ever is starting afterwards – will locate the already running instance and connect to it.) - Check that you have a container running locally, based on a Keycloak image, listening on port 5000:
- Now, let’s hit an end-point (e.g. “userEP”) with the browser. And really, the redirect works and we get a login screen displayed.
Note:- The redirect has worked. We are on accessing Keaycloak that runs on port 5000 (see URL: “localhost:5000/realms/quarkus/…”)
- Keycloak DevServices have already generated a realm within Keycloak (called “quarkus”) – as you can see in the headline above the login window.
- The redirect has worked. We are on accessing Keaycloak that runs on port 5000 (see URL: “localhost:5000/realms/quarkus/…”)
- Now, what shall we enter in the login page?
Well, also for this Quarkus has generated some bootstrapping data:- There is a user “alice” (password: “alice”) who has the “user” and “admin” role
- There is a user “bob” (password: “bob”) who has only the “user” role
- Before, we continue with the authentication workflow, let’s explore Keycloak a bit more:
- Keycloak has a graphical admin console where we can view and manage the object types that we will use later on.
- Let’s open the admin console in another browser window (http://localhost:5000/admin)
- In order to log into this admin console, you need to enter the credentials of the Keycloak admin user (not to be mixed up with the application users “alice” and “bob”). A default admin user has been created with the credentials “admin/admin”. Let’s use this to log into the admin console.
- In order to have a working environment, quarkus has already created a realm “quarkus”. A realm is an isolated environment within a Keycloak server which can be used to separate tenants. By default, the “master” realm is opened. Let’s switch to the “quarkus” realm (top right corner):
- Now, let’s explore the GUI of Keycloak to get familiar with the key concepts. In the left menu, click on the “Clients” view to get a listing of all clients within this realm. For us particularly interesting is the client “quarkus-app” which has also been automatically created by Quarkus. This “quarkus-app” refers to our ServiceA.
- In the view “Users” you will see all the 2 automatically generated user “alice” and “bob”. Moreover, you will see a service-account that has been generated for the quarkus-app:
- If you click on one of the users, you see the associated roles in the tab “Role mapping”:
- In the “Sessions” view, you will see all active sessions. As we have not yet logged in with any user, this list is empty.
- Keycloak has a graphical admin console where we can view and manage the object types that we will use later on.
- And that’s exactly, what we want to do now. In the login page (that you hopefully have left open – from step 9) enter the credentials “alice/alice”.
(Remark: If you are already confused with too many windows open, just hit again the end-point http://localhost:8000/serviceA/userEP)
And it works.
Congratulations! You have introduced Keycloak as an intermediary into the flow! - But actually, we haven’t applied any RBAC rules yet. Let’s do this now! The default users “alice” has the roles “user” and “admin” associated, the user “bob” only the the role “user”.
Thus, let’s add the RBAC rules as annotations to the end-points, e.g.
@RolesAllowed("admin") - That means, that “bob” should NOT be able to access the “adminEP” as he doesn’t have the admin role. Let’s validate this assumption!
But how to switch the user?
- First, we need to clean up the session which is active for some time. This can be done in the “Admin console” of Keycloak in the “Manage->Sessions” tab.
Click on “Logout all” - If you try to access the “adminEP” we are not redirected and it still works. Obviously, we are still authenticated as user “alice”, even tough we have killed the session.
That’s indeed somehow confusing. The reason is that the browser is caching the session id and the application is NOT contacting Keycloak each time for validation. The service so-to-say still assumes that the session and the token is valid. This is obviously a trade-off between minimizing the dependency on Keycloak and the traffic versus additional security.
In order to simulate a switch of users, we need to delete the cookie in the browser (or wait for some time till the token has expired!)
- First, we need to clean up the session which is active for some time. This can be done in the “Admin console” of Keycloak in the “Manage->Sessions” tab.
- The cookie can be killed by clicking the right mouse button in the browser window and choose “Inspect”. This opens a new view.
Switch to the “Application” tab and find the Cookies under (“Storage -> Cookies: http:localhost:8080”.
Then right click on the “q_session” cookie and choose “delete”. - Now, if you hit the adminEP you should be redirected again. If you enter the credentials “bob/bob” you should get a http 403.
Now, let’s change the configuration of Keycloak and add the “admin” role to “bob”. - In the Keycloak Admin Console, go to “Manage -> Users”. Click on “bob”, switch to the tab “Role Mappings” and add the role “admin” to the “Assigned Roles”.
- Now, try to access the “adminEP”. It still does NOT work?
Well, because we again still have the old session which doesn’t contain the new “admin” role. - We again have to initiate a “logout” and delete the cookie. After the new login, the access to the “adminEP” should now also work for bob.
Congratulations! You have enabled your application for OpenID Connect and have delegated the duties to Keycloak.
How does the JWT look like?
As you have seen, all the handling is now much simplified. Under-the-hood there is still a JWT generated and validated, but you don’t have to deal with that.
If you are interested to see the JWT (e.g. for debugging) there are again several possibles:
- You just copy the value of the cookie (“q_session”). It actually contains 3 tokens which are separated by a delimiter (“|”). These are the id, access and refresh token. You can explore each of them to better understand the concept of OAuth2 and OpenID Connect
- You inject the JWT in the receiver and access the whole or certain values. See the description in our previous blog.
In the header section of the GreetingResource class:
@Inject
JsonWebToken jwt;
You can then access the jwt object in any of the methods, e.g.:
return "The jwt is: "+ jwt.getRawToken();
Restricting Audience
As we have explained in the previous blog, it is a best-practice to restrict the audience of the JWT. This is a possibility for services to protect itself from arbitrary access.
In our scenario, this is a bit more complicated as we can’t just simple add this claim to the token, but need to configure the audience in Keycloak. But by doing this, we get a better understanding what’s happening under-the-hood and also explore some advanced concepts like client scopes.
- So far, ServiceB was using the same Keycloak client like ServiceB, namely the automatically created “quarkus-app”. Now, let’s create in our Keycloak realm a separate client for ServiceB that will consume the token.
In the Keycloak admin console go to the view “clients” (Make sure that you are in the right realm “quarkus”!!) and click on “Create client”. Choose the following values (if not specified you can leave it as is):
- Client type: OpenID Connect
- Client ID: serviceB
- Client authentication: On
- Authentication flow: uncheck everything
(Remark: As this client will only consume tokens, we don’t need to grant rights for any Authentication flow)
- Click on “Save” and then switch to the tab “Credentials”.
Copy the “Client Secret” - Create a “Client Scope” with a token mapper to audience “serviceB”:
Go to view “Client scopes” and click on “Create client scope” and choose the following values:- Name: adding-aud-serviceB
- Type: Default
- Click on “Save”
Switch to “Mappers” and click on “Configure a new mapper” - Choose “Audience” from the list and enter the following values:
- Name: add-serviceB
- Included Client Audience: serviceB
- Add to ID token: On
Click on “Save”
- Add this client scope to “quarkus-app”:
- Go to view “Clients” and click on “quarkus-app”
- Switch to tab “Client scopes”
- Click on “Add client scope”
- Choose the newly created “adding-aud-serviceB” and click on “Add->Default”
Now, the claim “audience: serviceB” will automatically be added to each token that is issued for the client “quarkus-app”.
- Now, let’s make sure that ServiceB is indeed running with the client credentials of the ServiceB client. This can be achieved by adding the following properties to ServiceB:
quarkus.oidc.client-id=serviceB
quarkus.oidc.credentials.secret=[The secret that we have copied from the keycloak admin in step 2] - In order to have ServiceB validate that the token is intended for it, add also the following:
quarkus.oidc.token.audience=serviceB - Now, test again the endpoints.
- You should get a valid response
- Now, try changing the audience property (added in step 8) to any other value:
You should get a HTTP 401
Congratulations! You have applied the “Least access” principle which means that each access should be limited to the absolute minimum.
Further use-cases
As explained, oAuth2 and OpenID Connect are perfectly equipped for securing a heterogeneous landscape with lots of services within your organization and beyond. It contains powerful concepts to establish very fine-grained RBAC, thus balancing security, implementation effort and user convenience. Keycloak is a very powerful open source technology that implement these standards and provide many capabilities on top. It would go beyond this blog series to explain all the possibilities, but we want to mention some areas that are absolutely worthwhile to explore further:
- Adding additional scopes to the JWT in order to specify which attributes an application can “see” from a user
- Adding multi-factor authentication to further enhance the security
- Using multiple identity stores and Authentication provider from multiple sources, e.g. from Social Media providers, github, etc.
- Customizing the Authentication work-flow
- Change to centralized authorization by specifying the access policies in Keycloak and further slimming down your application code.
Conclusion
Now, we have enabled our services for OpenID Connect which will make further addition of services much more simple. You are now also well equipped for raising the bar regarding security mechanism that might be required in the future (e.g. multi-factor-authentication with One-time-password).
In the next blog, we will look at a specific implementation pattern that allows to enable applications for OpenID connect without changing anything in the application. This is achieved by an API gateway that acts as the counterparty to Keycloak, obtaining and validating the JWT token.
Common pitfalls / Troubleshooting:
- Keycloak should automatically be started as part of the quarkus dev mode. It is using docker and testcontainers. In case, you want to use podman instead, check out this blog how to configure this.
- If you get an HTTP 401, try to understand which service reports this. Check the chain in the following order:
- Try to access the endpoint of ServiceA without any @RolesAllowed annoation and without calling ServiceB, e.g. for the userEP of ServiceA change the code to:
return "Hello, I'm here at ServiceA";return externalService.userEP(); - Try to inject a jwt into ServiceA and see whether the JWT is correct
- If the ServiceA seems to be correct, check whether ServiceB is reached
- If ServiceB is reached, check whether it receives the JWT token (again by injecting the JWT)
- Try to access the endpoint of ServiceA without any @RolesAllowed annoation and without calling ServiceB, e.g. for the userEP of ServiceA change the code to: