OpenID Federation with pac4j and CAS

We have made a long journey with pac4j and the OpenID federation protocol in this series of 3 articles:

As CAS v8.1.0 will also support the OpenID federation protocol as a server (OP), it is time to conclude this series with this fourth and final article, using pac4j and CAS only.

Like in our previous setup, we have 3 components:

Although we use the CAS server for the OP and the TA, we can’t use a single CAS node: we have two separate nodes of the CAS server, each supporting an exclusive role.

To be consistent with our previous installation:

For the CAS server nodes, we use this basic Maven overlay: https://github.com/casinthecloud/cas-overlay-demo.

Both our CAS servers have a similar pom.xml file:

<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 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.casinthecloud</groupId>
    <artifactId>cas-overlay-demo</artifactId>
    <version>8.1.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <dependencies>
        <dependency>
            <groupId>org.apereo.cas</groupId>
            <artifactId>cas-server-webapp${tomcat.properties}</artifactId>
            <version>${cas.version}</version>
            <type>war</type>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.apereo.cas</groupId>
            <artifactId>cas-server-support-oidc-federation</artifactId>
            <version>${cas.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.3.1</version>
                <configuration>
                    <failOnMissingWebXml>false</failOnMissingWebXml>
                    <recompressZippedFiles>false</recompressZippedFiles>
                    <archive>
                        <compress>false</compress>
                        <manifestFile>${project.build.directory}/war/work/org.apereo.cas/cas-server-webapp${tomcat.properties}/META-INF/MANIFEST.MF</manifestFile>
                    </archive>
                    <overlays>
                        <overlay>
                            <groupId>org.apereo.cas</groupId>
                            <artifactId>cas-server-webapp${tomcat.properties}</artifactId>
                        </overlay>
                    </overlays>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <release>${java.version}</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>4.1.0</version>
                <configuration>
                    <mainClass>org.apereo.cas.web.CasWebApplication</mainClass>
                    <excludes>
                        <exclude>
                            <groupId>org.apereo.cas</groupId>
                            <artifactId>cas-server-webapp-tomcat</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
        <finalName>cas</finalName>
    </build>

    <properties>
        <cas.version>8.1.0-RC1</cas.version>
        <java.version>25</java.version>
        <tomcat.properties>-tomcat</tomcat.properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

</project>

The OP has one more CAS dependency though (compared to the TA):

<dependency>
    <groupId>org.apereo.cas</groupId>
    <artifactId>cas-server-support-oidc</artifactId>
    <version>${cas.version}</version>
</dependency>

And the configuration specific to each server changes.

For the pac4j application, we use this simple demo: https://github.com/pac4j/simple-spring-boot-pac4j-demos/tree/oidc/src/main/java/org/pac4j/demos.

1) Setup a CAS server as the OP

In the application.yml file, let’s set up the CAS server to act as the trust anchor:

server.ssl.enabled: false
server.port: 8080

cas.tgc.secure: false
cas.tgc.same-site-policy: Lax

cas.tgc.crypto.enabled: false
cas.webflow.crypto.enabled: false

cas.server.name: http://localhost:${server.port}
cas.server.prefix: ${cas.server.name}/cas
cas.host.name: casop

cas.authn.oidc.jwks.file-system.jwks-file: file:./metadata/oidc.jwks
cas.authn.oidc.federation.role: OPENID_PROVIDER
cas.authn.oidc.federation.jwks-file: file:./metadata/federation.jwks
cas.authn.oidc.federation.authority-hints:
  - http://localhost:8082/cas/oidc
cas.authn.oidc.core.issuer: ${cas.server.prefix}/oidc

The configuration is quite easy: we set up the CAS server to run on http://localhost:8080/cas (no SSL, Lax policy, no cookie/webflow encryption, this is for development only).

For OIDC, we define its OIDC base URL (issuer) and its JWKS (./metadata/oidc.jwks).

And for the federation part, we set the OPENID_PROVIDER role, the specific JWKS (./metadata/federation.jwks) and the trust anchor:

cas.authn.oidc.federation.authority-hints:
  - http://localhost:8082/cas/oidc

2) Setup the pac4j application as the RP

In the pac4j ecosystem, Spring Boot is the most popular web stack, so we use the spring-webmvc-pac4j implementation in this simple demo: https://github.com/pac4j/simple-spring-boot-pac4j-demos/tree/oidc/src/main/java/org/pac4j/demos:

a) Dependencies

We need the following dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>spring-webmvc-pac4j</artifactId>
    <version>8.0.3</version>
</dependency>
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-oidc</artifactId>
    <version>6.5.5</version>
</dependency>

b) Properties

We run it on port 8081 thanks to the application.properties file:

server.port=8081
app.base-url=http://localhost:8081

c) SecurityConfig class

We update the SecurityConfig class to change the configuration for the federation:

package org.pac4j.demos;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
import org.pac4j.core.config.Config;
import org.pac4j.oidc.client.OidcClient;
import org.pac4j.oidc.config.OidcConfiguration;
import org.pac4j.oidc.config.method.PrivateKeyJwtClientAuthnMethodConfig;
import org.pac4j.oidc.federation.config.OidcTrustAnchorProperties;
import org.pac4j.springframework.config.Pac4jSecurityConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;

import java.util.List;

@Configuration
public class SecurityConfig extends Pac4jSecurityConfig {

    @Value("${app.base-url:http://localhost:8080}")
    private String baseUri;

    @Bean
    public Config config() {
        final var config = new OidcConfiguration();
        config.setAllowUnsignedIdTokens(true);

        final var rpJwks = config.getRpJwks();
        rpJwks.setJwksPath("file:./metadata/rpjwks.jwks");
        rpJwks.setKid("defaultjwks26");
        config.setClientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT);
        final var privateKeyJwtConfig = new PrivateKeyJwtClientAuthnMethodConfig(rpJwks);
        config.setPrivateKeyJWTClientAuthnMethodConfig(privateKeyJwtConfig);

        config.setRequestObjectSigningAlgorithm(JWSAlgorithm.RS256);

        final var federation = config.getFederation();

        federation.setTargetOp("http://localhost:8080/cas/oidc");
        final var trust = new OidcTrustAnchorProperties();
        trust.setIssuer("http://localhost:8082/cas/oidc");
        trust.setJwksPath("classpath:trustanchor.jwks");
        federation.getTrustAnchors().add(trust);

        federation.getJwks().setJwksPath("file:./metadata/oidcfede.jwks");
        federation.getJwks().setKid("mykeyoidcfede26");
        federation.setContactName("RP with CAS");
        federation.setContactEmails(List.of("[email protected]"));

        federation.setEntityId("http://localhost:8081");

        return new Config(baseUri + "/callback", new OidcClient(config));
    }

    @Override
    public void addInterceptors(final InterceptorRegistry registry) {
        addSecurity(registry, "OidcClient").addPathPatterns("/protected/**");
    }
}

The configuration is more complicated here.

We define a global JWKS for the RP that is also used for the private_key_jwt authentication method:

    final var rpJwks = config.getRpJwks();
    rpJwks.setJwksPath("file:./metadata/rpjwks.jwks");
    rpJwks.setKid("defaultjwks26");
    config.setClientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT);
    final var privateKeyJwtConfig = new PrivateKeyJwtClientAuthnMethodConfig(rpJwks);
    config.setPrivateKeyJWTClientAuthnMethodConfig(privateKeyJwtConfig);

For federation, we use:

    config.setRequestObjectSigningAlgorithm(JWSAlgorithm.RS256);

    final var federation = config.getFederation();

    federation.setTargetOp("http://localhost:8080/cas/oidc");
    final var trust = new OidcTrustAnchorProperties();
    trust.setIssuer("http://localhost:8082/cas/oidc");
    trust.setJwksPath("classpath:trustanchor.jwks");
    federation.getTrustAnchors().add(trust);

The target OP is of course the CAS server acting as an OP.

For the trust anchor, it’s the other CAS node (on port 8082) for which we retrieve the JWKS from http://localhost:8082/cas/oidc/.well-known/openid-federation and save it in the trustanchor.jwks file.

Example:

{
    "keys": [
        {
            "kty": "RSA",
            "e": "AQAB",
            "use": "sig",
            "kid": "19f7aaaa-c4d3-4c26-a26e-3ce4b8ab1beb",
            "n": "uXgnb...SEdSw"
        }
    ]
}

We also have a specific JWKS configuration dedicated to federation (displayed on the .well-known/openid-federation endpoint):

    federation.getJwks().setJwksPath("file:./metadata/oidcfede.jwks");
    federation.getJwks().setKid("mykeyoidcfede26");
    federation.setContactName("RP with CAS");
    federation.setContactEmails(List.of("[email protected]"));

    federation.setEntityId("http://localhost:8081");

d) Application class

We also need to update the Application class to add the mapping for the OpenID federation endpoint:

@Controller
public class Application {

    @Autowired
    private Config config;

    @RequestMapping(value = "/.well-known/openid-federation", produces = DefaultEntityConfigurationGenerator.CONTENT_TYPE)
    @ResponseBody
    public String oidcFederation() throws HttpAction {
        final var oidcClient = (OidcClient) config.getClients().findClient("OidcClient").get();
        return oidcClient.getConfiguration().getFederation().getEntityConfigurationGenerator().generateEntityStatement();
    }

    ...
}

3) Setup a CAS server as the trust anchor

In the application.yml file, let’s set up the CAS server as the trust anchor this time:

server.ssl.enabled: false
server.port: 8082

cas.tgc.secure: false
cas.tgc.same-site-policy: Lax

cas.tgc.crypto.enabled: false
cas.webflow.crypto.enabled: false

cas.server.name: http://localhost:${server.port}
cas.server.prefix: ${cas.server.name}/cas
cas.host.name: casta

cas.authn.oidc.federation.role: TRUST_ANCHOR
cas.authn.oidc.federation.jwks-file: file:./metadata/trustanchor.jwks
cas.authn.oidc.core.issuer: ${cas.server.prefix}/oidc
cas.authn.oidc.federation.subordinate-directory: ./subordinates

The configuration is close to that of the other CAS node, except that we define the TRUST_ANCHOR role as well as the file directory in which we will define its subordinates.

The subordinates are the entities for which the CAS server provides trust. They must be defined upfront with their metadata and their federation key(s).

Here, it will be the RP (the pac4j client, Spring Boot demo) and the OP (the other CAS server).

For the RP (pac4j), we call the URL: http://localhost:8081/.well-known/openid-federation. An entity statement is returned by the Spring Boot demo and we can decode it via any JWT tool or the jwt.io website.

The metadata and the keys from the jwks property (not in the metadata property) are the ones we use to build the subordinate rp.json file (placed in the ./subordinates directory):

{
    "entityId": "http://localhost:8081",
    "metadata": {
        "openid_relying_party": {
            "redirect_uris": [
                "http://localhost:8081/callback?client_name=OidcClient"
            ],
            "application_type": "web",
            "response_types": [
                "code"
            ],
            "grant_types": [
                "authorization_code"
            ],
            "scope": "openid email profile",
            "token_endpoint_auth_method": "private_key_jwt",
            "token_endpoint_auth_signing_alg": "RS256",
            "request_object_signing_alg": "RS256",
            "jwks": {
                "keys": [
                    {
                        "kty": "RSA",
                        "e": "AQAB",
                        "use": "sig",
                        "kid": "defaultjwks26",
                        "n": "v-zf7...G2tyw"
                    }
                ]
            },
            "client_registration_types": [
                "explicit",
                "automatic"
            ],
            "client_name": "RP with CAS",
            "contacts": [
                "[email protected]"
            ]
        }
    },
    "federationKeys": [
        {
            "kty": "RSA",
            "e": "AQAB",
            "use": "sig",
            "kid": "mykeyoidcfede26",
            "n": "uaov...EpbZQ"
        }
    ]
}

For the OP (CAS server), we do something similar and call the URL: http://localhost:8080/cas/oidc/.well-known/openid-federation to get the metadata and the federation keys and create the op.json file (in the ./subordinates directory).

{
    "entityId": "http://localhost:8080/cas/oidc",
    "metadata": {
        "openid_provider": {
            "DPopSigningAlgValuesSupported": [
                "RS256",
                "RS384",
                "RS512",
                "ES256",
                "ES384",
                "ES512"
            ],
            "request_parameter_supported": true,
            "pushed_authorization_request_endpoint": "http://localhost:8080/cas/oidc/oidcPushAuthorize",
            "introspection_signing_alg_values_supported": [
                "none",
                "RS256",
                "RS384",
                "RS512",
                "PS256",
                "PS384",
                "PS512",
                "ES256",
                "ES384",
                "ES512",
                "HS256",
                "HS384",
                "HS512"
            ],

            ...[TRUNCATED]...

            "registration_endpoint": "http://localhost:8080/cas/oidc/register",
            "request_object_signing_alg_values_supported": [
                "none",
                "RS256",
                "RS384",
                "RS512",
                "PS256",
                "PS384",
                "PS512",
                "ES256",
                "ES384",
                "ES512",
                "HS256",
                "HS384",
                "HS512"
            ],
            "request_object_encryption_alg_values_supported": [
                "RSA1_5",
                "RSA-OAEP",
                "RSA-OAEP-256",
                "A128KW",
                "A192KW",
                "A256KW",
                "A128GCMKW",
                "A192GCMKW",
                "A256GCMKW",
                "ECDH-ES",
                "ECDH-ES+A128KW",
                "ECDH-ES+A192KW",
                "ECDH-ES+A256KW"
            ]
        },
        "federation_entity": {
            "organization_name": "Apereo CAS",
            "contacts": []
        }
    },
    "federationKeys": [
        {
            "kty": "RSA",
            "e": "AQAB",
            "use": "sig",
            "kid": "0bf6c36e-1cba-41d3-a50e-a11881fd85e7",
            "n": "xoP5Q...3EkRw"
        }
    ]
}

4) Final test

With the RP, OP and TA started, we call http://localhost:8081 in the browser:

pac4j home page

Then, we click on the “Protected area” link.

On the CAS server login page, we use the pre-defined user: casuser / Mellon to log in:

pac4j home page

and we validate the authorizations:

pac4j home page

And it works:

pac4j home page

We are finally logged in to the pac4j application thanks to the OpenID Federation protocol and the two CAS server nodes.

So the CAS server can now act as a federated OP and as a trust anchor with the pac4j RP client.
Jérôme LELEU - October 2026