Authenticate Jakarta EE apps with Google using OpenID Connect

In one of my pet projects, I’m writing a Jakarta EE web application where I want users to authenticate using Google. Easy, you would say, as Jakarta EE 10 includes Jakarta Security 3.0, which has support for OpenID Connect authentication. Took me a bit more time to figure out how to get it working, and to save you from having to do that, here’s what I found.

The basics

As said, Jakarta Security 3.0 includes support for OpenID Connect authentication. The specification mentions the OpenID Connect Annotation as the way to configure the built-in OpenID Connect authentication mechanism.

The first step was to place that annotation in a central place: on an otherwise empty class, a bit like a configuration class in Spring. This annotation needs a handful of parameters:

@OpenIdAuthenticationMechanismDefinition(
    clientId = "${oidcConfig.clientId}",
    clientSecret = "${oidcConfig.clientSecret}",
    logout = @LogoutDefinition(
        redirectURI = "${baseURL}"
    ),
    providerURI = "${oidcConfig.configUrl}",
    redirectURI = "${oidcConfig.callbackUrl}",
    redirectToOriginalResource = true,
    useNonce = true,
    useSession = true
)

Most of these are parameters that I don’t want to include in my source code. Luckily, Jakarta Security leverages Jakarta Expression Language (EL) so it can resolve these values at runtime.

Externalising configuration

My first hurdle, being relatively new in the Jakarta EE ecosystem, was how Jakarta EL would resolve those values. In Spring Boot, you would expect an expression like ${oidcConfig.clientId} to automatically read a file application.yml or the like. Jakarta EE doesn’t have this built-in, so… where does that value come from? Jakarta EL comes with various built-in ELResolver implementations. One of them is a BeanELResolver that resolves expressions against existing beans.

So I’ve created an small bean to extract that configuration from environment variables. The @ApplicationScoped annotation marks this bean as being a singleton, so the same instance of this class will be used on every occasion where it is injected.

@ApplicationScoped
@Named("oidcConfig")
public class OidcConfig {
    private final String callbackUrl;
    private final String configUrl;
    private final String clientId;
    private final String clientSecret;

    public OidcConfig() {
        var environment = System.getenv();
        this.callbackUrl = environment.get("OPENID_CALLBACK_URL");
        this.configUrl = environment.get("OPENID_CONFIG_URL");
        this.clientId = environment.get("OPENID_CLIENT_ID");
        this.clientSecret = environment.get("OPENID_CLIENT_SECRET");
    }

    public String getCallbackUrl() {
        return callbackUrl;
    }

    public String getConfigUrl() {
        return configUrl;
    }

    public String getClientId() {
        return clientId;
    }

    public String getClientSecret() {
        return clientSecret;
    }
}

If you prefer to inject secrets into your application using files, I’ll leave that as an exercise to you.

Protecting resources

The next step is making sure that some parts of the application are indeed protected. I configure the application to only show these pages to users that have a particular role. To keep things easy, I want all pages that start with /secure to be protected. First, I have to declare a security role, and next, I need to put that as a constraint on a web resource collection. The relevant parts go into web.xml:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
                             https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
         version="6.0">

    <security-role>
        <role-name>customer</role-name>
    </security-role>

    <security-constraint>
        <web-resource-collection>
            <web-resource-name>Secured area</web-resource-name>
            <url-pattern>/secure/*</url-pattern>
        </web-resource-collection>
        <auth-constraint>
            <role-name>customer</role-name>
        </auth-constraint>
        <user-data-constraint>
            <transport-guarantee>CONFIDENTIAL</transport-guarantee>
        </user-data-constraint>
    </security-constraint>
</web-app>

Let’s break this down:

  1. First, the security role merely specifies that the role exists, but doesn’t enforce anything yet.
  2. Next, the web resource collection acts as a matcher, it declares which resources should be protected.
  3. To glue them together, the auth constraint enforces that a user should have the security role “customer” declared earlier.
  4. As an added bonus, the user data constraint ensures that access to the “/secure” part of the application requires a confidential connection. In practice, this usually translates into requiring the user to connect using Transport Layer Security (TLS).

That last point will also enforce the use of TLS during local development - consider using mkcert to make this part easier.

Assiging roles to users

When I had this, I thought I was good to go. But little did I know! I overlooked a crucial part in the Jakarta Security specification:

A public OpenID Connect Provider generally has no knowledge about roles or groups an end-user (caller) has in a client application (relying party), but a (private) OpenID Connect Provider operated by the same organisation may have. Therefore this specification allows groups to be provided by the client application or by the OpenID Connect Provider (or both).

When I found that, it finally started to click together for me. Of course, Google (or any other public identity provider) doesn’t know about my “customer” role, and they shouldn’t know about it!

Reading further, I found I had to implement an IdentityStore that would at least be capable of providing group names. An IdentityStore shows its capabilities through the validationTypes method. So I wrote a trivial implementation of that interface:

@ApplicationScoped
public class MyIdentityStore implements IdentityStore {
    @Override
    public Set<ValidationType> validationTypes() {
        return EnumSet.of(ValidationType.PROVIDE_GROUPS);
    }

    @Override
    public Set<String> getCallerGroups(final CredentialValidationResult validationResult) {
        return Set.of("consumer");
    }
}

Observe how the first method, validationTypes, tells the Jakarta Security implementation what this identity store can do - it is only capable of providing groups to a user. The getCallerGroups method does exactly that, in the most simple way: it generously grants everyone that is authenticated the same role, “consumer”.

As you may deduce, this means anybody can use the system as long as they are willing to authenticate with the identity provider(s) that I trust for my application.

Aside: more than one group?

As a little aside, let’s do a thought exercise and assume that some users are more equal than others. For instance, we would like to distinguish between “customers” and “loyal customers”. The latter would have an additional role “loyal_customer” and be eligeble for discounts, early access to products, you name it. The place to assign them their additional role would be the getCallerGroups method, by seeing if their identity exists in a database table, or by querying a remote API.

Logging out

Back to the more fundamental stuff. We’ve seen how we can authenticate users, how we can assign them the necessary role(s) to use the application. What if we want them to log out? It took me a while to figure this out, but it turned out to be relatively simple. The Jakarta Servlet Specification prescribes that implentations should offer a logout method on their HttpServletRequest implementation. The goal of that method is to reset the caller identity of a request. Call that method whenever you want to log out the user, and you’re good to go.