Secure a Quarkus API with Keycloak

Quarkus is a Java stack that is Kubernetes native, lightweight and fast. Quarkus can be used for any type of backend development, including API-enabled backends. Keycloak is an open source Single Sign On solution that can be used to secure APIs.

In this article, I’m describing how to secure a Quarkus API with Keycloak using JWT tokens.

Preparation

As a pre-requisite, install Maven, jq and jwt-cli.

Create a Quarkus project using either code.quarkus.io or Maven. Example shown below with Maven.

mvn io.quarkus:quarkus-maven-plugin:1.2.0.Final:create \
    -DprojectGroupId=fr.itix.test \
    -DprojectArtifactId=secured-rest \
    -DclassName="fr.itix.test.SecuredResource"

Enter the created directory.

cd secured-rest

Install and configure Keycloak

Download Keycloak and install it locally.

curl -L -o keycloak.tgz https://downloads.jboss.org/keycloak/9.0.0/keycloak-9.0.0.tar.gz
tar zxvf keycloak.tgz
mv keycloak-* keycloak

Create the Keycloak admin user.

./keycloak/bin/add-user-keycloak.sh -u admin -r master -p secret

Start the Keycloak server.

nohup ./keycloak/bin/standalone.sh -Djboss.socket.binding.port-offset=100 &

Quickly, the Keycloak admin console should be available at localhost:8180/auth/admin/. The login is admin and password is secret.

In the rest of this section, we will configure Keycloak with:

Authenticate as admin on Keycloak using the kcadm CLI.

./keycloak/bin/kcadm.sh config credentials --server http://localhost:8180/auth --realm master --user admin --client admin-cli --password secret

Create a realm named demo.

./keycloak/bin/kcadm.sh create realms -s realm=demo -s enabled=true -o

Create a client named quarkus-app.

./keycloak/bin/kcadm.sh create clients -r demo -s 'clientId=quarkus-app' -s 'standardFlowEnabled=false' -s 'directAccessGrantsEnabled=true' -s 'serviceAccountsEnabled=true' -s 'clientAuthenticatorType=client-secret' -s 'secret=s3cr3t'

The framework used by Quarkus to implement security is based on Eclipse MicroProfile - JWT RBAC Security (MP-JWT) and requires a claim named groups to hold the group membership.

So, we need to create a custom protocol mapper to map the user’s client role mapping to the groups claim.

CLIENT_ID="$(./keycloak/bin/kcadm.sh get clients -r demo -q clientId=quarkus-app |jq -r '.[0].id')"

./keycloak/bin/kcadm.sh create clients/$CLIENT_ID/protocol-mappers/models -r demo -f - <<EOF
{
  "name": "groups",
  "protocol":"openid-connect",
  "protocolMapper": "oidc-usermodel-client-role-mapper",
  "consentRequired": false,
  "config": {
    "multivalued": "true",
    "userinfo.token.claim": "true",
    "id.token.claim": "true",
    "access.token.claim": "true",
    "claim.name": "groups",
    "jsonType.label": "String",
    "usermodel.clientRoleMapping.clientId": "quarkus-app"
  }
}
EOF

Create two client roles in the quarkus-app client.

./keycloak/bin/kcadm.sh create clients/$CLIENT_ID/roles -r demo -s name=user -s 'description=Can access the application'
./keycloak/bin/kcadm.sh create clients/$CLIENT_ID/roles -r demo -s name=admin -s 'description=Can administer the application'

Create a user named jdoe and set its password to something easy to remember.

./keycloak/bin/kcadm.sh create users -r demo -s username=jdoe -s enabled=true -s firstName=John -s lastName=Doe
./keycloak/bin/kcadm.sh set-password -r demo --username jdoe --new-password password123

Give this user the client role user.

./keycloak/bin/kcadm.sh add-roles -r demo --uusername jdoe --cclientid quarkus-app --rolename user

Now Keycloak should be ready to secure your Quarkus API!

Secure the Quarkus API with Keycloak

Add the quarkus-smallrye-jwt extension to the Quarkus project.

./mvnw quarkus:add-extension -Dextension="quarkus-smallrye-jwt"

Configure the jwt extension to fetch the OpenID Connect Public Keys from Keycloak (mp.jwt.verify.publickey.location), enforce a validation of the issuer, preferred_username, and audience fields. That way, Quarkus takes care of validating the token for us.

cat > src/main/resources/application.properties <<EOF
mp.jwt.verify.publickey.location=http://localhost:8180/auth/realms/demo/protocol/openid-connect/certs
mp.jwt.verify.issuer=http://localhost:8180/auth/realms/demo
smallrye.jwt.verify.audience=quarkus-app
smallrye.jwt.require.named-principal=true
EOF

Replace src/main/java/fr/itix/test/SecuredResource.java with:

package fr.itix.test;

import java.security.Principal;

import javax.annotation.security.PermitAll;
import javax.annotation.security.RolesAllowed;
import javax.enterprise.context.RequestScoped;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.SecurityContext;
import javax.ws.rs.core.Response.Status;

import org.eclipse.microprofile.jwt.Claim;
import org.eclipse.microprofile.jwt.Claims;

@Path("/api")
@RequestScoped
public class SecuredResource {
    @Inject @Claim(standard = Claims.preferred_username)
    String username;

    @GET
    @Path("/helloEverybody")
    @Produces(MediaType.TEXT_PLAIN)
    @PermitAll
    public String helloEverybody() {
        return "Hello everybody !";
    }

    @GET
    @Path("/hello")
    @Produces(MediaType.TEXT_PLAIN)
    public String hello(@Context SecurityContext ctx) {
        String name = "dear unknown visitor";
        Principal caller =  ctx.getUserPrincipal();
        if (caller != null) {
            name = caller.getName();
        }

        return "hello " + name;
    }

    @GET
    @Path("/helloUsers")
    @Produces(MediaType.TEXT_PLAIN)
    @RolesAllowed({"user"})
    public String helloUsers() {
        return "hello user " + username;
    }

    @GET
    @Path("/helloAdmins")
    @Produces(MediaType.TEXT_PLAIN)
    @RolesAllowed({"admin"})
    public String helloAdmins() {
        return "hello admin " + username;
    }
}

This class defines four HTTP endpoints:

Run the quarkus project in dev mode.

./mvnw compile quarkus:dev

Query the Quarkus API

In another terminal, ensure the /api/helloEverybody endpoint is working and reachable without security.

$ curl http://localhost:8080/api/helloEverybody
Hello everybody !

You can query the /api/hello endpoint without any authentication.

$ curl http://localhost:8080/api/hello
hello dear unknown visitor

Now, get a token from Keycloak for user jdoe.

TOKEN="$(curl http://localhost:8180/auth/realms/demo/protocol/openid-connect/token -d grant_type=password -d client_id=quarkus-app -d client_secret=s3cr3t -d username=jdoe -d password=password123 -s |jq -r .access_token)"

When you display the issued token, it contains the standard OpenID Connect claims as well as the groups claim as instructed by our custom protocol mapper.

$ jwt decode "$TOKEN"

Token header
------------
{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "dCsrXmzTBFDbrXqd-paTe9rSti43lHSSrjqbdcAN9IM"
}

Token claims
------------
{
  "acr": "1",
  "aud": "account",
  "auth_time": 0,
  "azp": "quarkus-app",
  "email_verified": false,
  "exp": 1584454157,
  "family_name": "Doe",
  "given_name": "John",
  "groups": [
    "user"
  ],
  "iat": 1584453857,
  "iss": "http://localhost:8180/auth/realms/demo",
  "jti": "49e5a792-8c36-4202-bd37-7d06fa799fa4",
  "name": "John Doe",
  "nbf": 0,
  "preferred_username": "jdoe",
  "realm_access": {
    "roles": [
      "offline_access",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    },
    "quarkus-app": {
      "roles": [
        "user"
      ]
    }
  },
  "scope": "email profile",
  "session_state": "659d4c51-36a9-4bdc-ba4b-2ea84f3390cb",
  "sub": "0e83418b-0635-435e-9bac-dd3d3b87cba4",
  "typ": "Bearer"
}

If you provide a JWT token, the API will update its greeting message accordingly.

$ curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/hello
hello jdoe

When setting up Keycloak, we added jdoe to the user group. So, you should be able to query the /api/helloUsers endpoint.

$ curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/helloUsers
hello user jdoe

But you cannot yet query the /api/helloAdmins endpoint.

$ curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/helloAdmins
Forbidden

Give jdoe the client role admin.

./keycloak/bin/kcadm.sh add-roles -r demo --uusername jdoe --cclientid quarkus-app --rolename admin

Get a new token from Keycloak for user jdoe.

TOKEN="$(curl http://localhost:8180/auth/realms/demo/protocol/openid-connect/token -d grant_type=password -d client_id=quarkus-app -d client_secret=s3cr3t -d username=jdoe -d password=password123 -s |jq -r .access_token)"

You can now query the /api/helloAdmins endpoint.

$ curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/helloAdmins
hello admin jdoe

And this concludes this article on how to secure a Quarkus API with Keycloak!