How to secure a Shiro application with CAS (using pac4j)

Your application already uses Shiro: URL rules in shiro.ini, calls to SecurityUtils.getSubject(), role checks in the code. Now you need to connect it to a CAS server, while keeping those authorization rules.

Since the shiro-cas module was retired, Shiro no longer provides its own CAS client. We can use pac4j for the authentication and pass the result to Shiro.

This is the role of buji-pac4j. But there is a distinction to make: buji-pac4j is a bridge, not an authentication implementation. It transfers the pac4j profile to the Shiro subject. It cannot start a CAS login on its own.

So we also need a pac4j implementation:

The implementation handles CAS and produces a user profile. The bridge then logs that profile into Shiro through a dedicated realm, exposing the pac4j roles as Shiro roles. Your roles[...] rules and subject.hasRole checks can use them; we’ll look at the principals below.

For a new application, a pac4j implementation alone is simpler. The bridge is useful when you already have Shiro code to keep.

What you need:

1) Get the demo

The buji-pac4j-demo project is the reference for this guide: Shiro, the pac4j servlet filters and the bridge, configured entirely in shiro.ini, with a CAS login among others.

git clone https://github.com/pac4j/buji-pac4j-demo.git
cd buji-pac4j-demo
mvn clean package jetty:run

2) Add the Maven dependencies

On top of shiro-web, you need three pac4j artifacts: the pac4j implementation that authenticates, here the jakartaee-pac4j servlet filters, the CAS module, and the bridge.

<!-- Apache Shiro for web applications -->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-web</artifactId>
    <version>3.0.1</version>
</dependency>
<!-- the pac4j implementation: security, callback and logout filters -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>jakartaee-pac4j</artifactId>
    <version>8.0.3</version>
</dependency>
<!-- pac4j support for CAS -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-cas</artifactId>
    <version>6.5.8</version>
</dependency>
<!-- the bridge: pushes the pac4j profile into the Shiro subject -->
<dependency>
    <groupId>io.buji</groupId>
    <artifactId>buji-pac4j</artifactId>
    <version>10.0.0</version>
</dependency>

Each dependency has a job: without jakartaee-pac4j (or spring-webmvc-pac4j), nothing starts the login, so the bridge has no profile to pass to Shiro.

The versions above use Jakarta APIs. Shiro 3 does not need a jakarta classifier, and bridge version 10 targets pac4j 6 and Shiro 3. For Shiro 2 with javax.servlet, use bridge version 9.1 and the javaee-pac4j filters.

You may notice that we haven’t declared a realm yet. The bridge supplies a default Shiro INI alongside yours, loaded through its WebEnvironment. It declares config, clients, a Pac4jRealm and a subject factory.

On the pac4j side, it installs a ShiroProfileManager. Saving a profile logs the Shiro subject in; removing the profile logs it out. This is where the two security models meet.

3) Configure pac4j in shiro.ini

Everything else is plain Shiro INI. The config and clients objects come from the bridge; you declare the CAS client, plug it into clients, and define the pac4j filters as Shiro filters:

[main]
# the CAS client
casConfig = org.pac4j.cas.config.CasConfiguration
casConfig.loginUrl = https://www.casserverpac4j.dev/login

casClient = org.pac4j.cas.client.CasClient
casClient.configuration = $casConfig

# the callback URL and the list of clients (the "clients" object is declared by the bridge)
clients.callbackUrl = http://localhost:8080/callback
clients.clients = $casClient

# the pac4j filters, declared like any Shiro filter
casSecurityFilter = org.pac4j.jee.filter.SecurityFilter
casSecurityFilter.config = $config
casSecurityFilter.clients = CasClient

callbackFilter = org.pac4j.jee.filter.CallbackFilter
callbackFilter.config = $config

pac4jLogout = org.pac4j.jee.filter.LogoutFilter
pac4jLogout.config = $config
pac4jLogout.defaultUrl = /
pac4jLogout.destroySession = true

[urls]
/protected/** = casSecurityFilter
/callback = callbackFilter
/pac4jLogout = pac4jLogout
/admin/** = casSecurityFilter, roles[ROLE_ADMIN]
/** = anon

For CAS itself, casConfig.loginUrl is the only mandatory setting. pac4j derives the validation URL from it and uses CAS 3.0 by default to retrieve user attributes. If validation needs an internal address, set casConfig.prefixUrl = http://cas-internal:8080/cas. For an older server, casConfig.protocol = CAS20 selects CAS 2.0.

clients.callbackUrl, with ?client_name=CasClient appended by pac4j, is the service URL seen by CAS. Register it as explained in the service registration section of the Spring Boot guide.

Now follow a request to /protected/**: casSecurityFilter starts the login for an anonymous user. On the way back, callbackFilter validates the service ticket, saves the pac4j profile and, through the bridge, logs the Shiro subject in. The same callback also receives CAS single logout notifications. You can declare more SecurityFilter instances for other clients or authorizers.

Pay attention to /admin/**: its chain first starts CAS authentication, then applies roles[ROLE_ADMIN]. Only the first matching URL chain runs, so the /protected/** rule does not protect the admin pages.

The web.xml is the standard Shiro one: the EnvironmentLoaderListener and the ShiroFilter mapped on /*. Nothing pac4j-specific goes there.

4) Map the CAS user to Shiro roles

We now have an authenticated user, but where do the Shiro roles come from? Pac4jRealm exposes the pac4j profile’s roles with their names unchanged. CAS, however, sends attributes, not pac4j roles.

An authorization generator makes the connection. For example, we can derive a role from a memberOf attribute released by the CAS service:

package org.example.security;

import java.util.Collection;
import java.util.Optional;
import org.pac4j.core.authorization.generator.AuthorizationGenerator;
import org.pac4j.core.context.CallContext;
import org.pac4j.core.profile.UserProfile;

public class RoleAuthorizationGenerator implements AuthorizationGenerator {

    @Override
    public Optional<UserProfile> generate(final CallContext ctx, final UserProfile profile) {
        profile.addRole("ROLE_USER");
        final var groups = profile.getAttribute("memberOf");
        final var adminGroup = "cn=admins,ou=groups,dc=example,dc=com";
        if (adminGroup.equals(groups)
                || (groups instanceof Collection<?> values && values.contains(adminGroup))) {
            profile.addRole("ROLE_ADMIN");
        }
        return Optional.of(profile);
    }
}

Add these declarations to the [main] section, before [urls]:

roleGenerator = org.example.security.RoleAuthorizationGenerator
casClient.authorizationGenerator = $roleGenerator

Replace adminGroup with the exact group value released by your CAS server. Group membership must be controlled by administrators; a substring check could grant access to a different group with a similar name.

Shiro permissions work the same way: put a list of permission strings in the profile attribute named by Pac4jRealm.SHIRO_PERMISSIONS, and subject.isPermitted(...) sees them.

5) Access the authenticated user

We can now read the user through the Shiro API. The subject is authenticated and its primary principal is the CAS principal, usually the username. A Pac4jPrincipal in the principal collection gives us the full pac4j profile:

final var subject = SecurityUtils.getSubject();
subject.isAuthenticated();               // true after the CAS login
subject.hasRole("ROLE_ADMIN");           // from the authorization generator
subject.getPrincipal();                  // the CAS principal

final var principal = subject.getPrincipals().oneByType(Pac4jPrincipal.class);
final var profile = (CasProfile) principal.getProfile();
profile.getAttribute("email");
profile.getAttributes();                 // everything the release policy allowed

An empty attribute map can mean the release policy of the service is too restrictive, or the server still uses the CAS 2.0 protocol. To use another attribute as the principal name, set pac4jRealm.principalNameAttribute = email in the [main] section.

6) Logout

For logout, use /pac4jLogout, the URL configured above. The pac4j filter and bridge remove the profile and log the Shiro subject out together; destroySession invalidates the session. Shiro’s built-in logout filter does not perform this whole operation.

To also end the CAS SSO session, enable central logout on the filter:

pac4jLogout.centralLogout = true
pac4jLogout.defaultUrl = http://localhost:8080/

pac4j then redirects the browser to the CAS /logout endpoint. The CAS server must allow the requested return URL. If you accept a dynamic url parameter, restrict it separately with logoutUrlPattern.

When CAS single logout is enabled, the server can notify participating services on their callback URLs. Processing those notifications also requires a session store that can recover and destroy the recorded session; verify this with your Shiro session configuration. A local logout alone does not notify other applications.

7) Run the application

mvn clean package jetty:run

Open http://localhost:8080/protected/index.jsp: you are redirected to the CAS login page, then back with a service ticket, and the page shows both the Shiro principals and the pac4j profile. Then try http://localhost:8080/admin/index.jsp: access is denied unless CAS releases the exact administrator group expected by the generator.

If something goes wrong:

Switching to OIDC or SAML

Add pac4j-oidc or pac4j-saml, declare the corresponding client in the INI, reference it in clients.clients and update the security filter’s clients property. Keep the bridge and realm, but adapt profile casts, attribute-to-role mappings and callback/logout registration to the provider. The protocol-specific setup is described in the OIDC guide and the SAML guide. The demo’s shiro.ini declares all three side by side. A CAS server can also be configured as an OIDC or SAML provider; choose the protocol required by your deployment.

Learn more

Discover more pac4j frameworks and more authentication mechanisms