Multi-Tenant OpenID Connect Bearer Token Authentication with Quarkus

Authentication and authorization in an application is an important and sometimes complex topic. The sheer amount of official Quarkus guides only on this single aspect gives a first impression. Additionally, although there is a fundamental difference between authentication and authorization, they are often mixed and used similarly, which leads to additional confusion.

In this post I would like to show with a slightly simplified example from practice how to secure a Quarkus REST API using OIDC bearer authentication in a multi-tenant environment. As of writing this, the current Quarkus version is 3.6.4.

The platform under consideration here consists of

  • a Quarkus backend based on natively compiled AWS lambdas providing REST services
  • a frontend which uses AWS Cognito connected to an Azure Active Directory (AAD) for authentication and authorization
  • a second frontend which uses a self-hosted Keycloak for authentication and authorization

Simplified architecture with the two frontends, Cognito, Azure Active Directory, Keycloak and the AWS lambda Quarkus backend (click to enlarge)::image-scalable

The two frontends retrieve JWT access and refresh tokens from Cognito and Keycloak respectively via an OIDC authorization code flow and authenticate themselves at the backend with the retrieved access tokens. Since both frontends share the same backend, different tenants are used to distinguish how the incoming access tokens are verified depending on the called REST endpoints. Authorization is done using a RBAC (Role-Based Access Control) approach with the different roles being administered in the AAD and Keycloak respectively.

Configuring Multi-Tenant OIDC Bearer Authentication

Base Configuration

First, the Quarkus OpenID Connect Extension has to be added, e.g. with the Quarkus CLI by executing

1quarkus extension add oidc

The basic setup in the application.properties is short:

1quarkus.oidc.enabled=true
2quarkus.security.auth.enabled-in-dev-mode=false
3quarkus.http.auth.proactive=false
  • The extension itself has to be activated by setting the parameter quarkus.oidc.enabled to true.
  • To make local testing easier, the entire security layer can be deactivated in dev mode by setting the parameter quarkus.security.auth.enabled-in-dev-mode to false. This is optional and makes the most sense once the entire setup process has been completed and everything is working as intended.
  • Finally, the proactive HTTP authentication that Quarkus uses by default must be deactivated. This is mandatory when using annotation-based tenant resolving as in our case.

Tenant Resolving

Quarkus offers three options for determining the tenant to be used for a specific endpoint:

In our case, the request paths do not contain any tenant-specific parts, therefore the first two options do not make much sense, and we opted for the annotation-based approach using the @Tenant annotation.

Cognito-Based OIDC Bearer Authentication with the Default Tenant

The first frontend uses AWS Cognito for retrieving access and refresh tokens. The user-related information itself is maintained in an Azure Active Directory and the relevant parts are automatically replicated into a user pool by Cognito. Cognito is configured as the default tenant that Quarkus uses if nothing else is specified for a secured REST endpoint.

1# default tenant
2quarkus.oidc.auth-server-url=https://cognito-idp.<aws-region>.amazonaws.com/<user-pool-id>
3quarkus.oidc.client-id=<cognito-app-client-id>
4quarkus.oidc.discovery-enabled=true
5quarkus.oidc.token.required-claims.client_id=${quarkus.oidc.client-id}
6quarkus.oidc.authentication.user-info-required=true
7quarkus.oidc.roles.source=userinfo
8quarkus.oidc.roles.role-claim-path=custom:roles
  • The Cognito base URL for the user pool has to be specified with the parameter quarkus.oidc.auth-server-url.
  • Although we are using local Json Web Key token verification only and don't need remote token introspection, a client ID should always be set using the quarkus.oidc.client-id parameter. We set it to the corresponding Cognito app client ID.
    AWS console showing the Cognito app client with its associated ID, which is used e.g. in the aud and client_id claims (click to enlarge)::image-scalable
  • For the sake of simplicity we use the OIDC discovery feature which is supported by both Cognito and Quarkus by setting quarkus.oidc.discovery-enabled to true. As a result, Quarkus automatically queries the discovery endpoint at https://cognito-idp.<aws-region>.amazonaws.com/<user-pool-id>/.well-known/openid-configuration to obtain all the metadata it needs e.g. for token verification. Alternatively, these parameters can also be configured manually. For our application, the additional call to the discovery endpoint does not have a negative impact on overall performance, but for performance-critical applications it may be better to configure it directly and avoid the additional call.
  • Unlike with ID tokens, Cognito does not currently add an aud claim to the access tokens that could be checked. Instead, it uses the client_id claim and sets it to the Cognito app client ID (just like the aud claim for ID tokens). We also have this verified by setting quarkus.oidc.token.required-claims.client_id accordingly.
  • The AAD not only manages the authentication data, but also the roles assigned to the individual users. The roles are not sent along with the access token from the frontend, so we let Quarkus automatically query the UserInfo endpoint to retrieve additional user-related claims. This is done by setting quarkus.oidc.authentication.user-info-required to true.
  • Next, we instruct Quarkus to extract the roles from the response received from the UserInfo endpoint so that we can use them to authorize the caller. This is done by setting quarkus.oidc.roles.source to userinfo.
  • In our case Cognito does not return the roles in one of the standard role claims, but in the custom:roles claim, so we have to tell Quarkus to use exactly this claim to extract the roles. We do so by setting quarkus.oidc.roles.role-claim-path accordingly.

One problem we faced was that the roles in the custom:roles field come as a JSON array like [\"admin\"] and these values are not extracted correctly by Quarkus, i.e. the extracted role name in this case would be ["admin"] instead of admin which is not the desired behavior. Therefore, we have implemented a custom SecurityIdentityAugmentor that copies all other fields and manually adds the roles to the security identity in the desired way (logging omitted here):

 1@ApplicationScoped
 2public class RolesAugmentor implements SecurityIdentityAugmentor {
 3    @Inject
 4    ObjectMapper objectMapper;
 5
 6    @Override
 7    public Uni<SecurityIdentity> augment(SecurityIdentity securityIdentity, 
 8                                         AuthenticationRequestContext authenticationRequestContext) {
 9        return Uni.createFrom().item(build(securityIdentity));
10    }
11
12    Supplier<SecurityIdentity> build(SecurityIdentity identity) {
13        if (identity.isAnonymous()) {
14            return () -> identity;
15        } else {
16            QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder()
17                    .addAttributes(identity.getAttributes())
18                    .addCredentials(identity.getCredentials())
19                    .setPrincipal(identity.getPrincipal())
20                    .setAnonymous(identity.isAnonymous());
21
22            identity.getRoles().stream()
23                    .map(role -> {
24                        if (role.startsWith("[")) { // roles as JSON array
25                            try {
26                                return objectMapper.readValue(role, new TypeReference<>() {});
27                            } catch (JsonProcessingException e) {
28                                return List.of("");
29                            }
30                        } else {
31                            return List.of(role); // regular non-array role
32                        }
33                    })
34                    .flatMap(List::stream)
35                    .filter(StringUtils::isNotEmpty)
36                    .forEach(builder::addRole);
37            return builder::build;
38        }
39    }
40}

Keycloak-Based OIDC Bearer Authentication with a Second Tenant

The second tenant is configured in a very similar way, specifying the base URL of the authentication server, the client ID, again the OIDC discovery feature and additional claims to verify.

1# named tenant "keycloak-tenant"
2quarkus.oidc.keycloak-tenant.auth-server-url=https://my.keycloak.example/auth/realms/xyz
3quarkus.oidc.keycloak-tenant.client-id=AppId2
4quarkus.oidc.keycloak-tenant.discovery-enabled=true
5quarkus.oidc.keycloak-tenant.token.required-claims.azp=${quarkus.oidc.keycloak-tenant.client-id}

Note that the configuration keys now contain the named tenant keycloak-tenant, which we will reference later when securing the corresponding endpoints. It is not necessary to query the UserInfo endpoint for this tenant as Keycloak is configured in such a way that all the user's roles are already supplied in the groups claim in the access token. Quarkus correctly extracts the roles from this claim and they can be used directly.

Like Cognito, Keycloak does not provide an aud claim in its access tokens. Instead, it sends the client ID in the azp claim, which we verify in the same way as for the default tenant.

Securing REST Endpoints

Now that the backend is configured, we secure the endpoints. For this we can use the well known annotations provided in the jakarta.annotation.security package like @RolesAllowed, @PermitAll and so on. Quarkus also defines useful convenience annotations such as @Authenticated in the io.quarkus.security package.

An example resource for the Cognito tenant looks like this:

 1@Path("/frontend1-path")
 2@Consumes(MediaType.APPLICATION_JSON)
 3@Produces(MediaType.APPLICATION_JSON)
 4public class GreetingResource {
 5    @GET
 6    @Path("/unauthenticated")
 7    @PermitAll
 8    public Response getUnauthenticated() {
 9        // ...
10    }
11    
12    @GET
13    @Path("/any-role")
14    @Authenticated
15    public Response getWithAnyRole() {
16        // ...
17    }
18    
19    @GET
20    @Path("/admin")
21    @RolesAllowed("admin")
22    public Response getWithAdminRoleOnly() {
23        // ...
24    }
25}
  • The first endpoint is unauthenticated, it is open to the public. The @PermitAll annotation is optional in this case, omitting it has the same effect, but to make things explicit, it is good practice to use it.
  • The second endpoint is secured by @Authenticated which means all authenticated users can access the endpoint regardless of their roles.
  • The last endpoint is an admin endpoint. Only authenticated users with the admin role can access it.

Note that no tenant information is specified for these endpoints, hence Quarkus uses the default one automatically i.e. the Cognito tenant.

Let's take a look at how the endpoints for the second tenant can be secured.

 1@Path("/frontend2-path")
 2@Consumes(MediaType.APPLICATION_JSON)
 3@Produces(MediaType.APPLICATION_JSON)
 4public class GreetingResource {
 5    @GET
 6    @Path("/unauthenticated")
 7    @Tenant("keycloak-tenant")
 8    @PermitAll
 9    public Response getUnauthenticated() {
10        // ...
11    }
12    
13    @GET
14    @Path("/any-role")
15    @Tenant("keycloak-tenant")
16    @Authenticated
17    public Response getWithAnyRole() {
18        // ...
19    }
20    
21    @GET
22    @Path("/admin")
23    @Tenant("keycloak-tenant")
24    @RolesAllowed("admin")
25    public Response getWithAdminRoleOnly() {
26        // ...
27    }
28}

The usage is almost identical except for the additional @Tenant annotations. Quarkus evaluates the annotations and then uses the second tenant for these endpoints accordingly. The value passed to the annotation must correspond to the value of the named tenant in the application.properties configuration shown above.

JUnit Testing Secure REST Endpoints

As always, automated tests are just as important as the application code itself. There are a couple of ways to test secure endpoints, for example via Wiremock or via auxiliary annotations like @TestSecurity and @OidcSecurity provided by Quarkus. The latter has the advantage that it is simple, lightweight and intuitive. The dependency quarkus-test-security-oidc makes these annotations available, e.g. when using Maven by adding

1<dependency>
2    <groupId>io.quarkus</groupId>
3    <artifactId>quarkus-test-security-oidc</artifactId>
4    <scope>test</scope>
5</dependency>

to the pom.xml.

The following example shows how these annotations can be used:

 1@QuarkusTest
 2@TestHTTPEndpoint(GreetingResource.class)
 3@DisplayName("The GreetingResource")
 4class GreetingResourceTest {
 5    @Test
 6    @TestSecurity(user = "JUnitTestUser", roles = "admin")
 7    @OidcSecurity(userinfo = {
 8        @UserInfo(key = "preferred_username", value = "JUnitTestUser@example.com"),
 9        @UserInfo(key = "given_name", value = "James J."),
10        @UserInfo(key = "family_name", value = "Unit"),
11    })
12    @DisplayName("should return the admin response as expected")
13    void admin() {
14        RestAssured.given()
15                .headers(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
16                .when()
17                .get("/admin")
18                .then()
19                .statusCode(Response.Status.OK.getStatusCode());
20    }
21}
  • @TestSecurity defines the values for the security identity during the test like the username and its roles. Note that implementations of SecurityIdentityAugmentor are not executed here, i.e. the role value must be the one already correctly augmented.
  • Additional OIDC values can be set with @OidcSecurity, such as the UserInfo to be used during the test.

Since it would be cumbersome to place these annotations on every test, we implement a custom meta-annotation @AdminRoleTest like

 1@Retention(RetentionPolicy.RUNTIME)
 2@Target({ElementType.METHOD, ElementType.TYPE})
 3@TestSecurity(user = "JUnitTestUser", roles = "admin")
 4@OidcSecurity(userinfo = {
 5        @UserInfo(key = "preferred_username", value = "JUnitTestUser@example.com"),
 6        @UserInfo(key = "given_name", value = "James J."),
 7        @UserInfo(key = "family_name", value = "Unit"),
 8})
 9public @interface AdminRoleTest {
10}

and use it on tests as follows

 1@QuarkusTest
 2@TestHTTPEndpoint(GreetingResource.class)
 3@DisplayName("The GreetingResource")
 4class GreetingResourceTest {
 5    @Test
 6    @AdminRoleTest
 7    @DisplayName("should return the admin response as expected")
 8    void admin() {
 9        RestAssured.given()
10                .headers(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
11                .when()
12                .get("/admin")
13                .then()
14                .statusCode(Response.Status.OK.getStatusCode());
15    }
16}

This allows easy and efficient testing of secure endpoints.

Conclusion

In this example, we have seen how REST endpoints of a Quarkus application providing backend services for different frontends can be secured using OIDC bearer token authentication while using different tenants at the same time. The implementation can be tested very elegantly via JUnit tests using the annotations shown.

As mentioned at the beginning, authentication and authorization can be a complex topic, but at the same time it is essential to carefully secure applications nowadays. Therefore, it should always be approached calmly and not hastily. Last but not least, I would like to recommend the two most important official guides on this whole subject area - OpenID Connect (OIDC) Bearer Token Authentication and Using OpenID Connect (OIDC) Multi-Tenancy - as a reference.


Feedback Welcome

I am always happy to receive comments, corrections, improvements or feedback in general! Please drop me a note on Mastodon or by E-Mail anytime.