Eclipse MicroProfile JWT Authentication API

The Payara Platform 6.2024.11 provides MicroProfile JWT Authentication 2.1.

Background

The JWT Authentication API was designed to provide application callers with the ability to authenticate themselves using a JWT token. A JWT token is essentially a string of JSON with fields for specifying the caller/username and the groups the caller is in.

To prevent tampering, the JSON token is cryptographically signed. Note that it’s only signed, not fully encrypted. This means that the caller/username and the groups are in plain text, but because of the signature can’t be changed.

The complete specification can be found on the Eclipse MicroProfile website.

Breaking changes introduced in MicroProfile JWT Authentication 2.1 are listed in the official specification under Incompatible Changes

The MicroProfile JWT Authentication specification currently does not define any means for preemptive authentication, that is, authenticating when the target resource (such as a JAX-RS end-point) is not protected / constrained by a security role.
Practically this means that if a JWT token is sent alongside a request to a public / unchecked resource, MicroProfile JWT implementations will ignore it. This limitation will be addressed in a later version of the JWT specification.

Jakarta Security and JWT

Payara Server and Payara Micro implement the JWT authentication mechanism as a normal Jakarta Security (JSR 375) authentication mechanism.

This specifically means that when MP JWT authentication is used on Payara Server or Payara Micro, there’s an HttpAuthenticationMechanism CDI bean enabled that can be intercepted or decorated using the standard CDI APIs. Likewise, there’s an IdentityStore CDI bean enabled, which can be intercepted or decorated too.

By providing additional IdentityStore beans, an application can, if needed, augment the JWT identity store, for instance by providing extra roles.

Vending tokens

A JWT token, like most API tokens, is typically vended from a website for use with the services provided by that site. For instance, from Google or Facebook can obtain such a token for usage with their respective APIs.

Though JWT tokens are more universal in format, they are still intended for use with a specific service (website / API). This is encoded in the mandatory iss ("issuer") field. The value of the field can be anything that the intended service can recognize (see below).

For example:

{
    "iss": "fish.payara.example",
    "jti": "a-123",
    "sub": "24400320",
    "aud": "s6BhdRkqt3",
    "upn": "test",
    "groups": [
        "architect",
        "master",
        "leader",
        "dev"
    ]
}

A JWT also must be signed, and in the case of MicroProfile JWT this must be RSASSA-PKCS-v1_5 using the SHA-256 hash algorithm.

One way to do this signing is by first generating an SSH keypair as follows:

  1. Open a terminal

  2. Generate the base key by entering: openssl genrsa -out baseKey.pem

  3. From the base key generate the PKCS#8 private key: openssl pkcs8 -topk8 -inform PEM -in baseKey.pem -out privateKey.pem -nocrypt

  4. And generate the public key: openssl rsa -in baseKey.pem -pubout -outform PEM -out publicKey.pem

Then put privateKey.pem on the root of the classpath (for instance, in a Maven project put it in src/main/resources) and use the following code to create the signed JSON token as a string:

@RequestScoped
public class TokenGenerator{

    public static String generateJWTString(String jsonResource) {
        byte[] byteBuffer = new byte[16384];
        currentThread().getContextClassLoader()
                       .getResource(jsonResource)
                       .openStream()
                       .read(byteBuffer);

        JSONParser parser = new JSONParser(DEFAULT_PERMISSIVE_MODE);
        JSONObject jwtJson = (JSONObject) parser.parse(byteBuffer);

        long currentTimeInSecs = (System.currentTimeMillis() / 1000);
        long expirationTime = currentTimeInSecs + 1000;

        jwtJson.put(Claims.iat.name(), currentTimeInSecs);
        jwtJson.put(Claims.auth_time.name(), currentTimeInSecs);
        jwtJson.put(Claims.exp.name(), expirationTime);

        SignedJWT signedJWT = new SignedJWT(new JWSHeader
                                            .Builder(RS256)
                                            .keyID("/privateKey.pem")
                                            .type(JWT)
                                            .build(), parse(jwtJson));

        signedJWT.sign(new RSASSASigner(readPrivateKey("privateKey.pem")));

        return signedJWT.serialize();
    }

    public static PrivateKey readPrivateKey(String resourceName){
        byte[] byteBuffer = new byte[16384];
        int length = currentThread().getContextClassLoader()
                                    .getResource(resourceName)
                                    .openStream()
                                    .read(byteBuffer);

        String key = new String(byteBuffer, 0, length).replaceAll("-----BEGIN (.*)-----", "")
                                                      .replaceAll("-----END (.*)----", "")
                                                      .replaceAll("\r\n", "")
                                                      .replaceAll("\n", "")
                                                      .trim();

        return KeyFactory.getInstance("RSA")
                         .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(key)));
    }
}

The code here assumes the location of the raw JSON file on the classpath being passed in, the private key on the root of the classpath and the result as a string.

If other input/outputs are needed that should be easy to do using the above code as a starting point.

Accepting tokens

Public Key configuration

In order to validate the signature of a provided token is valid, an application that uses JWT Authentication has to provide the public key to the MicroProfile JWT Authentication implementation either using the standard MicroProfile Configuration options or using the vendor-specific option.

mp.jwt.verify.publickey

The mp.jwt.verify.publickey microprofile config property allows the Public Key text itself to be supplied as a string.

For e.g : mp.jwt.verify.publickey=joer4fghieEM3UmZQcFRvNzM2fhMnJ6QV45ghRCdTQ1SnYwdXBkRVpjc54645jNJc65XltamJaUmtwZ1RSOEIxOWJfcl

mp.jwt.verify.publickey.location

The mp.jwt.verify.publickey.location microprofile config property allows for an external or internal location of Public Key to be specified. The value may be a relative path or a URL.

For e.g : mp.jwt.verify.publickey=/META-INF/publicKey.pem

public key vendor-specific configuration

In Payara this is done by placing the public key such as generated above as publicKey.pem on the root of the application’s classpath. For example, when using a Maven project as src/main/resources/publicKey.pem.

Vendor-specific option for supplying the public key will always take precedence.

Issuer configuration

Next to providing the public key, an application that uses JWT Authentication has to provide the issuer (corresponding to the iss field in the JSON token) it’s willing to accept.

Issuer can be provided to the MicroProfile JWT Authentication implementation either using the standard MicroProfile Config option or using the vendor-specific option.

mp.jwt.verify.issuer

The mp.jwt.verify.issuer microprofile config property allows for the expected value of the iss claim to be specified.

issuer vendor-specific configuration

For the Payara Platform, this is done by placing a properties file named payara-mp-jwt.properties on the root of the application’s classpath. For example, when using a Maven project as src/main/resources/payara-mp-jwt.properties. The properties file should contain the key accepted.issuer with as value the same value that of the iss field in the vended token, e.g. fish.payara.example as per the example JSON token shown above.

Vendor-specific option for supplying the issuer will always take precedence.

Namespaced claims configuration

Authentication services (like auth0, connect2id) offer the chance to add custom claims to JWT tokens but also enforce a namespaced format to avoid possible collisions with standard OpenID Connect claims.

In the Payara Platform, namespaced claims configuration is done by placing a properties file named payara-mp-jwt.properties on the root of the application’s classpath.

For example, when using a Maven project as src/main/resources/payara-mp-jwt.properties. These properties file should contain the Boolean property enable.namespace and the optional property custom.namespace.

enable.namespace

If this is true, the default https://payara.fish/mp-jwt/ namespace will be used and the parser will look out for namespaced claims.

For example:

If following JSON is the token payload and enable.namespace property is true:

{
  "https://payara.fish/mp-jwt/groups": ["admin", "read", "write"],
  "https://payara.fish/mp-jwt/upn": "test",
  "iss": "https://test.auth.com/",
  "sub": "5b2856bf8763ef356976dca3"
}

Then the JSON Parser search for namespace prefixed claims, remove the namespace from claim name, allow the processing of the token as usual.

custom.namespace

When the custom.namespace property is set, it will always take precedence over the default namespace and be used instead.

Disabling Type Claim Verification

The MicroProfile JWT Authentication specification currently mandates that the type claim (typ) of any authorization token parsed by the container is present and is set to the JWT value.

However, the current RFC document (RFC 7519) that defines the JWT standard states that this claim is optional:

5.1. “typ” (Type) Header Parameter

The “typ” (type) Header Parameter defined by [JWS] and [JWE] is used by JWT applications to declare the media type [IANA.MediaTypes] of this complete JWT. This is intended for use by the JWT application when values that are not JWTs could also be present in an application data structure that can contain a JWT object; the application can use this value to disambiguate among the different kinds of objects that might be present.

…​

Use of this Header Parameter is OPTIONAL.

For this reason, some third-party token issuers may generate tokens that are not compatible with the MicroProfile JWT specification. The Payara Platform allows to set this verification off, so you can use the disable.type.verification custom property and set its value to true to this effect.

This property has to be defined in the payara-mp-jwt.properties configuration file described in the previous section.
Keep in mind that tokens which are missing their type claim and are propagated to other services running on other Eclipse MicroProfile runtimes might be rejected, as the specification mandates the inclusion of the claim.

Caching the Public Key

By default, the public key retrieved by the mp.jwt.verify.publickey.location configuration property will be cached in memory for 5 minutes after being read from either a local file or a remote location. You can modify this "time-to-live" which determines how long the key stays cached in memory in the case you are dealing with long-lived keys, this is done by setting the publicKey.cache.ttl custom property.

This property has to be defined in the payara-mp-jwt.properties configuration file described in the previous section.
The value of the publicKey.cache.ttl property is defined in milliseconds, so keep this in mind when modifying the property

Activating JWT Authentication

An application activates the JWT authentication mechanism and identity store by annotating a class in the application, for instance, the JAX-RS Application class, with @LoginConfig(authMethod = "MP-JWT").

Protecting Jakarta REST Services Endpoints

MicroProfile JWT Authentication specifies that Jakarta REST services endpoints are to be secured by using the jakarta.annotation.security.RolesAllowed annotation. Note that while this is a general annotation, for Jakarta EE it’s only EJBs that interprets this.

The Jakarta REST Services specification itself does not specify that this annotation should work on resource classes or methods and hence most implementations do not support it out of the box. The Payara Platform supports this behaviour out of the box in a proprietary manner. This support holds for all types of authentication mechanisms, e.g. BASIC, and not just JWT.

For example:

@ApplicationScoped
@Path("/resource")
@Produces(TEXT_PLAIN)
public class Resource {

    @Inject
    private Principal principal;

    @GET
    @Path("/protected")
    @RolesAllowed("architect")
    public String protectedResource() {
        return
            "This is a protected resource \n" +
            "web username: " + principal.getName() + "\n";
    }
}

Accessing a Protected Endpoint

With the generateJWTString() method as presented above and the JWT token residing in a file called jwt-token.json on the classpath, a request to a JWT protected endpoint can be done as follows using the Jakarta REST Services client API:

public class Application{

    public static void main(String[] args){
        var response = newClient()
                     .target(
                         URI.create(new URL(base, "resource/protected").toExternalForm()))
                     .request(TEXT_PLAIN)
                     .header(AUTHORIZATION, "Bearer " + generateJWTString("jwt-token.json"))
                     .get(String.class);
    }
}

With base being the context where the application is deployed, e.g. http://example.com/myapp

Switching off @RolesAllowed Support

The out-of-the-box support of @RolesAllowed for Jakarta REST Services resources can be switched off by setting the <jaxrs-roles-allowed-enabled> tag in WEB-INF/glassfish-web.xml to false.

For more information see the documentation for the jaxrs-roles-allowed-enabled element.