DEV Community

Jérôme LELEU
Jérôme LELEU

Posted on Originally published at pac4j.org

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Here are the main points in this setup:

  • Session handling: SessionHandler must run before pac4j on the protected, callback and logout routes. VertxSessionStore reads and writes that session.

  • Session-store configuration: the pac4j handlers register their session store in the Config, so there is no need to call config.setSessionStoreFactory(...). An explicitly configured factory is preserved.

  • Protected routes: SecurityHandler redirects anonymous users to the configured CAS server. For authenticated users, it sets the Vert.x user and passes control to our handler. SecurityHandlerOptions also accepts setAuthorizers and setMatchers. For example, "admin" can refer to a role check declared with config.addAuthorizer("admin", new RequireAnyRoleAuthorizer("ROLE_ADMIN")).

  • Login callback: CallbackHandler validates the CAS service ticket, saves the profile and redirects to the requested page or the default URL. setRenewSession(true) renews the session identifier to protect against session fixation. The callback's POST route and its BodyHandler also receive CAS single logout notifications when the user logs out elsewhere.

  • Local logout: LogoutHandler removes the profile. With setDestroySession(true), it destroys the Vert.x session as well.

  • Event loop: register all three pac4j handlers with .handler(...). They run the pac4j logic through executeBlocking, off the event loop, so no blockingHandler wrapper is needed.

  • Multiple instances: for clustered deployments, see the session stores and clustering guide.

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.");
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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…

Top comments (0)