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
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.enabledtotrue. - 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-modetofalse. 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-idparameter. We set it to the corresponding Cognito app client ID.
- For the sake of simplicity we use the OIDC discovery feature which is supported by both Cognito and Quarkus by setting
quarkus.oidc.discovery-enabledtotrue. As a result, Quarkus automatically queries the discovery endpoint athttps://cognito-idp.<aws-region>.amazonaws.com/<user-pool-id>/.well-known/openid-configurationto 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
audclaim to the access tokens that could be checked. Instead, it uses theclient_idclaim and sets it to the Cognito app client ID (just like theaudclaim for ID tokens). We also have this verified by settingquarkus.oidc.token.required-claims.client_idaccordingly. - 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-requiredtotrue. - 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.sourcetouserinfo. - In our case Cognito does not return the roles in one of the standard role claims, but in the
custom:rolesclaim, so we have to tell Quarkus to use exactly this claim to extract the roles. We do so by settingquarkus.oidc.roles.role-claim-pathaccordingly.
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
@PermitAllannotation 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
@Authenticatedwhich means all authenticated users can access the endpoint regardless of their roles. - The last endpoint is an admin endpoint. Only authenticated users with the
adminrole 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}
@TestSecuritydefines the values for the security identity during the test like the username and its roles. Note that implementations ofSecurityIdentityAugmentorare 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.