How to secure a Vert.x client application with CAS (using pac4j)

Let’s connect a Vert.x Web application to a CAS server. We’ll use the vertx-pac4j library to add three handlers to our router: SecurityHandler, CallbackHandler and LogoutHandler.

The CAS server handles the login page and authenticates the user. It can be the Apereo server already used by your organization, or the public pac4j test server we’ll use below.

The example uses vertx-pac4j v7.1.0, Vert.x v5 and Java 17. The CAS client is configured just as in the Spring Boot CAS guide.

For a more complete example, see the vertx-pac4j-demo.

1) Create the project

Start from an empty Maven project with Java 17 or later:

mkdir -p vertx-cas-app/src/main/java/org/example
cd vertx-cas-app

Create a pom.xml at the project root. The compiler targets Java 17, and the exec plugin will run the App class we write in section 7:

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.example</groupId>
    <artifactId>vertx-cas-app</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- Add the dependencies from section 2 here. -->
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.16.0</version>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <version>3.6.4</version>
                <configuration>
                    <mainClass>org.example.App</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

2) Add the Maven dependencies

Inside the <dependencies> element, add Vert.x Web, the Vert.x integration for the handlers and the CAS module for the client:

<dependency>
    <groupId>io.vertx</groupId>
    <artifactId>vertx-web</artifactId>
    <version>5.2.0</version>
</dependency>
<!-- pac4j integration for Vert.x -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>vertx-pac4j</artifactId>
    <version>7.1.0</version>
</dependency>
<!-- pac4j support for CAS -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-cas</artifactId>
    <version>6.5.9</version>
</dependency>

vertx-pac4j v7.1 targets Vert.x v5 and pac4j v6.

3) Write the security configuration

Create src/main/java/org/example/SecurityConfigFactory.java. It builds the pac4j Config with the CAS client:

package org.example;

import org.pac4j.cas.client.CasClient;
import org.pac4j.cas.config.CasConfiguration;
import org.pac4j.core.config.Config;
import org.pac4j.core.config.ConfigFactory;

public class SecurityConfigFactory implements ConfigFactory {

    @Override
    public Config build(final Object... parameters) {
        // configuration of the authentication via the CAS protocol
        final var casConfiguration = new CasConfiguration("https://www.casserverpac4j.dev/login");
        return new Config("http://localhost:8080/callback", new CasClient(casConfiguration));
    }
}

The only mandatory CAS setting is the login URL. pac4j derives the ticket validation URL from it and uses CAS 3.0 by default, so the validation response can include user attributes.

On the server side, CAS must recognize our application as a service. The public test server accepts any service. With your own CAS server, register the full callback URL, including the ?client_name=CasClient suffix added by pac4j, as a CAS service in your CAS server. The service registration section of the Spring Boot guide shows how.

4) Wire the handlers into the router

Create src/main/java/org/example/ServerVerticle.java. We install the session handler first, then the pac4j handlers on their routes. Add the protectedPage helper from section 5 inside this class before compiling:

package org.example;

import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.BodyHandler;
import io.vertx.ext.web.handler.SessionHandler;
import io.vertx.ext.web.sstore.LocalSessionStore;
import org.pac4j.cas.profile.CasProfile;
import org.pac4j.vertx.VertxProfileManager;
import org.pac4j.vertx.VertxWebContext;
import org.pac4j.vertx.context.session.VertxSessionStore;
import org.pac4j.vertx.handler.impl.CallbackHandler;
import org.pac4j.vertx.handler.impl.CallbackHandlerOptions;
import org.pac4j.vertx.handler.impl.LogoutHandler;
import org.pac4j.vertx.handler.impl.LogoutHandlerOptions;
import org.pac4j.vertx.handler.impl.SecurityHandler;
import org.pac4j.vertx.handler.impl.SecurityHandlerOptions;

public class ServerVerticle extends AbstractVerticle {

    @Override
    public void start(final Promise<Void> startPromise) {
        final var router = Router.router(vertx);

        // the Vert.x session, and the pac4j session store on top of it
        final var vertxSessionStore = LocalSessionStore.create(vertx);
        router.route().handler(SessionHandler.create(vertxSessionStore));
        final var sessionStore = new VertxSessionStore(vertxSessionStore);

        final var config = new SecurityConfigFactory().build();

        // the CAS login protects /protected
        final var securityOptions = new SecurityHandlerOptions().setClients("CasClient");
        router.get("/protected").handler(new SecurityHandler(vertx, sessionStore, config, securityOptions));
        router.get("/protected").handler(rc -> protectedPage(rc, sessionStore));

        // the CAS server redirects the user here with the service ticket
        final var callbackOptions = new CallbackHandlerOptions().setDefaultUrl("/").setRenewSession(true);
        final var callback = new CallbackHandler(vertx, sessionStore, config, callbackOptions);
        router.get("/callback").handler(callback);
        router.post("/callback").handler(BodyHandler.create().setMergeFormAttributes(true));
        router.post("/callback").handler(callback);

        // logs the user out
        final var logoutOptions = new LogoutHandlerOptions().setDefaultUrl("/").setDestroySession(true);
        router.get("/logout").handler(new LogoutHandler(vertx, sessionStore, logoutOptions, config));

        router.get("/").handler(rc -> rc.response().end("<a href='/protected'>Protected area</a>"));

        vertx.createHttpServer().requestHandler(router).listen(8080)
            .onSuccess(server -> startPromise.complete())
            .onFailure(startPromise::fail);
    }
}

Here are the main points in this setup:

5) Access the authenticated user

Add this helper method to ServerVerticle. It reads the profile from the RoutingContext and session store and returns plain text:

private static void protectedPage(final RoutingContext rc, final VertxSessionStore sessionStore) {
    final var profileManager = new VertxProfileManager(new VertxWebContext(rc), sessionStore);
    final var profile = (CasProfile) profileManager.getProfiles().get(0);
    rc.response().putHeader("Content-Type", "text/plain; charset=UTF-8")
        .end("Hello " + profile.getId() + "\nAttributes: " + profile.getAttributes()
            + "\nVisit /logout to sign out.");
}

The identifier of a CasProfile is the CAS principal, usually the username, and its attributes are exactly those released by the service’s attribute release policy on the CAS server. An empty attribute map can mean that the service does not release the expected attributes.

You can also stay with the Vert.x API: on a protected route, rc.user() is a Pac4jUser, and its profiles() method returns the same list of profiles.

6) Logout

Our /logout route ends the local session. To also end the SSO session on the CAS server, add a second handler with central logout enabled inside ServerVerticle.start, before starting the HTTP server:

final var centralLogoutOptions = new LogoutHandlerOptions()
    .setDefaultUrl("http://localhost:8080/")
    .setLogoutUrlPattern("http://localhost:8080/.*")
    .setLocalLogout(true)
    .setDestroySession(true)
    .setCentralLogout(true);
router.get("/centralLogout").handler(new LogoutHandler(vertx, sessionStore, centralLogoutOptions, config));

pac4j clears the local profile and redirects the browser to CAS /logout. CAS must allow the requested return URL. setLogoutUrlPattern validates an optional dynamic url parameter (setDefaultUrl selects the default return URL).

CAS can also send a back-channel logout notification to /callback, without the browser’s session cookie. pac4j uses its ticket-to-session mapping to find the session and removes the profile.

To destroy the whole session on a CAS notification, set your own org.pac4j.core.logout.handler.DefaultSessionLogoutHandler on the Config in SecurityConfigFactory.build, before returning it:

final var sessionLogoutHandler = new DefaultSessionLogoutHandler();
sessionLogoutHandler.setDestroySession(true);
config.setSessionLogoutHandler(sessionLogoutHandler);

Create a new instance rather than modifying the default handler returned by config.getSessionLogoutHandler(), which is shared by all Config objects. This setting is different from LogoutHandlerOptions.setDestroySession(true), which controls our /logout route.

7) Run the application

Create src/main/java/org/example/App.java to deploy the verticle:

package org.example;

import io.vertx.core.Vertx;
import java.util.concurrent.CountDownLatch;

public class App {

    public static void main(final String[] args) throws Exception {
        final var vertx = Vertx.vertx();
        try {
            vertx.deployVerticle(new ServerVerticle())
                .toCompletionStage().toCompletableFuture().get();
            new CountDownLatch(1).await();
        } finally {
            vertx.close().toCompletionStage().toCompletableFuture().get();
        }
    }
}

The main method waits for deployment to succeed, then stays alive so exec:java keeps the application running. From the directory containing pom.xml, run:

mvn clean compile exec:java

Open http://localhost:8080/protected. You are redirected to the CAS login page, then sent back with a service ticket, and the protected page shows your profile.

If CAS answers “Application Not Authorized to Use CAS”, the service is not registered or its pattern does not match the full callback URL.

If the login loops or loses its state, the SessionHandler is missing or registered after the pac4j handler: it must come first, on the protected route and on the callback.

8) Switching to OIDC or SAML

Add pac4j-oidc or pac4j-saml, replace the CasClient and update SecurityHandlerOptions. Adapt the profile cast and attribute mapping, register callback/logout URLs, and keep body parsing on the callback for SAML POST responses. The SAML setup also needs a keystore and metadata exchange.

The protocol-specific setup is described in the OIDC guide and the SAML guide. A CAS server can also be configured as an OIDC or SAML provider.

9) Learn more

Discover more pac4j frameworks and more authentication mechanisms…