WildFly 19 includes support for Microprofile JWT Api. In this tutorial we will see how to set up and deploy a REST Application which uses Microprofile JWT for Role Based Access Control. The application will run on the top of Wildly 19 and uses Keycloak as Identity and Access management service.


Today, the most common solutions for handling security of RESTful microservices are by means of solid standard which are based on OAuth2, OpenID Connect and JSON Web Token (JWT). In a nutshell JWT (JSON Web Token) is a compact, URL-safe means of representing claims to be transferred between two identities. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted.

Triggering the "jwt" subsystem on WildFly

In WildFly, the JWT API is provided by means of this extension:

<extension module="org.wildfly.extension.microprofile.jwt-smallrye"/>

With the current release of WildFly, there are no specific properties you can set into the microprofile-jwt-smallrye. However for the purpose of using its API, we can set some specific attributes of JWT in the Microprofile configuration file (microprofile-config.properties). But let's go with order. The first thing we need to do is marking a JAX-RS Application as requiring JWT RBAC.
This can be done through the annotation @LoginConfig to your existing @javax.ws.rs.core.Application subclass. So, here is our first element we will include in our application:

package com.mastertheboss.jwt;

import javax.annotation.security.DeclareRoles;
import javax.ws.rs.ApplicationPath;
import javax.ws.rs.core.Application;
import org.eclipse.microprofile.auth.LoginConfig;

@ApplicationPath("/rest")
@LoginConfig(authMethod = "MP-JWT", realmName = "myrealm")
@DeclareRoles({"admin","user"})
public class JaxRsActivator extends Application {
    
}

Next, we will add a secure REST Endpoint, which contains the following methods:

import org.eclipse.microprofile.jwt.Claim;
import org.eclipse.microprofile.jwt.Claims;
import javax.annotation.security.DenyAll;
import javax.annotation.security.RolesAllowed;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import javax.ws.rs.*;
import java.util.Set;

@Path("customers")
@ApplicationScoped
@Produces("application/json")
@Consumes("application/json")
@DenyAll
public class CustomerEndpoint {

    @Inject
    @Claim(standard = Claims.groups)
    private Set<String> groups;

    @Inject
    @Claim(standard = Claims.sub)
    private String subject;

    @GET
    @Path("goadmin")
    @RolesAllowed("admin")
    public String adminMethod() {
        return "You are logged with " +this.subject + ": " + this.groups.toString();
    }
    @GET
    @Path("gouser")
    @RolesAllowed("user")
    public String userMethod() {
        return "You are logged with " +this.subject + ": " + this.groups.toString();
    }
}

As you can see:

  • The adminMethod is allowed only to users belonging to the "admin" Role.
  • the userMethod is allowed only to users belonging to the "user" Role.

In order to return the username and the groups whom the user belongs to, we use the @Claim annotation which captures a set of claims. The JWT specification lists several “registered claims” to achieve specific goals. All of them, however, are optional. The mandatory ones are:

  • exp: This will be used by the MicroProfile JWT implementation to ensure old tokens are rejected.
  • sub: This will be the value returned from any getCallerPrinciple() calls made by the application.
  • groups: This will be the value used in any isCallerInRole() calls made by the application and any @RolesAllowed checks.

JWT settings required in your application

The next thing we need to do is configuring the public key to verify the signature of the JWT that is attached in the Authorization header. This is typically configured within the src/main/resources/META-INF/microprofile-config.properties this way:

mp.jwt.verify.publickey.location=/META-INF/keycloak-public-key.pem

Since MP JWT 1.1, however the key may be provided as a string in the mp.jwt.verify.publickey config property or as a file location or URL.

If you are using Keycloak version 6.0.0, or newer, this is even simpler as there is built-in Client Scope which makes it really easy to issue tokens and you don't have to add the public key to the PEM file. Instead you point to Keycloak JWK's endpoint.
So, once you have defined your Keycloak realm with a Client configuration bound to it, you can just add a reference to it into the src/main/resources/META-INF/microprofile-config.properties file

Here is how to do it in practice:

mp.jwt.verify.publickey.location=http://localhost:8180/auth/realms/myrealm/protocol/openid-connect/certs
mp.jwt.verify.issuer=http://localhost:8180/auth/realms/myrealm

In the above configuration, we have assumed that you are running a realm named "myrealm" and that Keycloak is running on localhost:8180. In the second part of this tutorial we will learn how to configure Keycloak so that it can work as authentication service for our JWT application.

Now complete the configuration adding in your web.xml:

<web-app version="3.1"
         xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd">

    <context-param>
        <param-name>resteasy.role.based.security</param-name>
        <param-value>true</param-value>
     </context-param>

     <security-role>
         <role-name>admin</role-name>
         <role-name>user</role-name>
     </security-role>
</web-app>

So we have enavled Role Based Security in our application and declared the available Roles.

Compiling the project

In order to be able to compile applications using JWT, you need to include the following dependency in your application:

<dependency>
  <groupId>org.eclipse.microprofile.jwt</groupId>
  <artifactId>microprofile-jwt-auth-api</artifactId>
  <version>1.1</version>
</dependency>

In addition, your application also needs to include jaxrs, cdi and jboss-annotation API in order to build the above example:

<dependency>
  <groupId>jakarta.enterprise</groupId>
  <artifactId>jakarta.enterprise.cdi-api</artifactId>
  <scope>provided</scope>
</dependency>
<dependency>
  <groupId>org.jboss.spec.javax.ws.rs</groupId>
  <artifactId>jboss-jaxrs-api_2.1_spec</artifactId>
  <scope>provided</scope>
</dependency>
<dependency>
  <groupId>org.jboss.spec.javax.annotation</groupId>
  <artifactId>jboss-annotations-api_1.3_spec</artifactId>
  <scope>provided</scope>
</dependency>

Finally, we will import the jakartaee8-with-tools BOM to add version for our dependencies:

<dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.wildfly.bom</groupId>
        <artifactId>wildfly-jakartaee8-with-tools</artifactId>
        <version>${version.server.bom}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
</dependencyManagement>

Setting up a Keycloak Realm

In order to run our example you can import the following Realm: https://github.com/fmarchioni/mastertheboss/blob/master/micro-services/mp-jwt-demo/myrealm.json
This can be done in one step by launching Keycloak's docker image with the KEYCLOAK_IMPORT option to import our sample domain.
So let's copy the file myrealm.json into a folder:

$ cp myrealm.json /tmp

Next start up Keycloak as follows:

docker run --rm  \
   --name keycloak \
   -e KEYCLOAK_USER=admin \
   -e KEYCLOAK_PASSWORD=admin \
   -e KEYCLOAK_IMPORT=/tmp/myrealm.json  -v /tmp/myrealm.json:/tmp/myrealm.json \
   -p 8180:8180 \
   -it quay.io/keycloak/keycloak:7.0.1 \
   -b 0.0.0.0 \
   -Djboss.http.port=8180 \
   -Dkeycloak.profile.feature.upload_scripts=enabled  

Now login into Keycloak console (http://localhost:8180) with the credentials admin/admin. The following Realm should be available:

jwt tutorial wildfly keycloak
Within the Realm, a Client application named jwt-client has been added:

jwt tutorial wildfly keycloak
The jwt-client contains the following settings to use OpenId-connect as Client Protocol, using a Confidential Access type, and redirecting to the root URL of WildFly (http://localhost:8080).
Within the Credentials tab, you will see the Client secret ("mysecret") needed to authenticate:

jwt tutorial wildfly keycloak

In order to provide support for the OAuth 2 scope parameter, which allows a client application to request claims or roles in the access token, we have added the following Client scope named "roles":

jwt tutorial wildfly keycloak
Lastly, the following users have been added:

jwt tutorial wildfly keycloak

  • The "admin" user (credentials: admin/test) belongs to "admin" group
  • The "test" user (credentials:test/test) belongs to "user" group

Testing our Application

In order to test our application, we will build a simple Test class which uses REST Assured API:

public class SampleEndpointTest {

    String keycloakURL ="http://localhost:8180";

    @Test
    public void testJWTEndpoint() {

        String secret ="mysecret";
        RestAssured.baseURI = keycloakURL;
        Response response = given().urlEncodingEnabled(true)
                .auth().preemptive().basic("jwt-client", secret)
                .param("grant_type", "password")
                .param("client_id", "jwt-client")
                .param("username", "test")
                .param("password", "test")
                .header("Accept", ContentType.JSON.getAcceptHeader())
                .post("/auth/realms/myrealm/protocol/openid-connect/token")
                .then().statusCode(200).extract()
                .response();

        JsonReader jsonReader = Json.createReader(new StringReader(response.getBody().asString()));
        JsonObject object = jsonReader.readObject();
        String userToken = object.getString("access_token");

        response = given().urlEncodingEnabled(true)
                .auth().preemptive().basic("jwt-client", secret)
                .param("grant_type", "password")
                .param("client_id", "jwt-client")
                .param("username", "admin")
                .param("password", "test")
                .header("Accept", ContentType.JSON.getAcceptHeader())
                .post("/auth/realms/myrealm/protocol/openid-connect/token")
                .then().statusCode(200).extract()
                .response();

        jsonReader = Json.createReader(new StringReader(response.getBody().asString()));
        object = jsonReader.readObject();
        String adminToken = object.getString("access_token");

        RestAssured.baseURI = "http://localhost:8080/jwt-demo/rest/jwt";

        given().auth().preemptive()
                .oauth2(userToken)
                .when().get("/gouser")
                .then()
                .statusCode(200);

        given().auth().preemptive()
                .oauth2(adminToken)
                .when().get("/goadmin")
                .then()
                .statusCode(200);

     }

}

The testJWTEndpoint method executes a POST request against the Auth URL of our Keycloak Realm. The request contains as a parameter the username and password along with the Client's ID ("jwt-client"), and its secret ("mysecret"). The RESTAssured fluent's API verifies that a Status code of 200 is returned and finally it returns the Response object.

We have then extracted the token contained in the JSON Response, under the key "access_token".

Once that you have obtained the token for both the "test" user and the "admin" user, we will use them both to invoke the available endpoints and verifying that the status code is 200.

Testing our application using curl

We can also test our application using curl, using the same logic as our REST Assured Test class:

#Get a token for an user belonging to "user" group
export TOKEN=$(\
curl -L -X POST 'http://localhost:8180/auth/realms/myrealm/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=jwt-client' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_secret=mysecret' \
--data-urlencode 'scope=openid' \
--data-urlencode 'username=test' \
--data-urlencode 'password=test'  | jq --raw-output '.access_token' \
 )

The token will be saved into the environment variable TOKEN. We can verify the token content as follows:

#Display token
JWT=`echo $TOKEN | sed 's/[^.]*.\([^.]*\).*/\1/'`
echo $JWT | base64 -d | python -m json.tool

Here is our JSON Token:

{
    "acr": "1",
    "allowed-origins": [
        "http://localhost:8080"
    ],
    "aud": "account",
    "auth_time": 0,
    "azp": "jwt-client",
    "email": "tester@localhost",
    "email_verified": false,
    "exp": 1582653314,
    "family_name": "Tester",
    "given_name": "Theo",
    "groups": [
        "offline_access",
        "uma_authorization",
        "user"
    ],
    "iat": 1582653014,
    "iss": "http://localhost:8180/auth/realms/myrealm",
    "jti": "ad8715e6-17a2-4093-b7cd-ea14f60f7706",
    "name": "Theo Tester",
    "nbf": 0,
    "preferred_username": "test",
    "realm_access": {
        "roles": [
            "offline_access",
            "uma_authorization",
            "user"
        ]
    },
    "resource_access": {
        "account": {
            "roles": [
                "manage-account",
                "manage-account-links",
                "view-profile"
            ]
        }
    },
    "scope": "openid email profile",
    "session_state": "c842f816-92ba-45fd-ab1c-14bfabf49f64",
    "sub": "a19b2afc-e96e-4939-82bf-aa4b589de136",
    "typ": "Bearer"
}

Now let's test our application using this token. At first we will attempt to use the REST API "/customers/gousers" which is authorized for the "user" Role:

curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/jwt-demo-1.0.0-SNAPSHOT/rest/customers/gouser

The above test should pass, returning the user's role as output.

Next, we will attempt to call the "/customers/goadmin" API using our Token:

curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/jwt-demo-1.0.0-SNAPSHOT/rest/customers/goadmin

 A 401 Unauthorized error should be returned, as our Token does not authorize us to access an API that requires the "admin" Role.

To be able to call the "/customers/goadmin" API, we need to request a Token for the "admin" user as follows:

export TOKEN=$(\
curl -L -X POST 'http://localhost:8180/auth/realms/myrealm/protocol/openid-connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=jwt-client' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'client_secret=mysecret' \
--data-urlencode 'scope=openid' \
--data-urlencode 'username=admin' \
--data-urlencode 'password=test'  | jq --raw-output '.access_token' \
 )

Source code

The source code for this tutorial is available at: https://github.com/fmarchioni/mastertheboss/tree/master/micro-services/mp-jwt-demo

0
0
0
s2sdefault