Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

How to secure a Spring WebFlux client application with OIDC (using pac4j)

Spring WebFlux is Spring's reactive alternative to Spring MVC: it uses non-blocking I/O and Reactor's Mono and Flux to handle concurrent requests with a small pool of threads. Both frameworks share the same controller an

Spring WebFlux is Spring's reactive alternative to Spring MVC: it uses non-blocking I/O and Reactor's Mono and Flux to handle concurrent requests with a small pool of threads. Both frameworks share the same controller annotations.

To migrate, replace spring-boot-starter-web with spring-boot-starter-webflux, adapt servlet APIs to WebFilter, ServerWebExchange and WebSession, and compose asynchronous operations with reactive types. Replace blocking calls with non-blocking APIs or move them to worker threads.

For authentication, spring-webflux-pac4j runs pac4j's synchronous security logic on worker threads. We'll reuse the OIDC configuration from the Spring Boot guide and adapt the web integration.

The example uses Spring Boot 3, Java 17 and spring-webflux-pac4j 3.0.2, with the default OIDC authorization code flow.

1) Add the Maven dependencies

Start with a Spring Boot 3 Maven application using the WebFlux starter and the Spring Boot Maven plugin. Add these dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Spring 6 logging bridge; version managed by Spring Boot -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jcl</artifactId>
</dependency>
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>spring-webflux-pac4j</artifactId>
    <version>3.0.2</version>
</dependency>
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-oidc</artifactId>
    <version>6.5.9</version>
</dependency>

Why declare spring-jcl explicitly? pac4j 6.5.9 excludes it from its transitive spring-core dependency, but Spring 6 still needs it at runtime.

The spring-webflux-pac4j-boot-demo contains a broader application. To adapt it, replace its security configuration with the one below. Place the following classes under the package scanned by your @SpringBootApplication.

2) Configure pac4j and protect the routes

First, let's create the OIDC client and protect the routes under /protected/:

package org.example;

import org.pac4j.core.config.Config;
import org.pac4j.core.matching.matcher.PathMatcher;
import org.pac4j.oidc.client.OidcClient;
import org.pac4j.oidc.config.OidcConfiguration;
import org.pac4j.springframework.web.SecurityFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan(basePackages = "org.pac4j.springframework.web")
public class SecurityConfig {

    @Bean
    public Config config() {
        final var oidc = new OidcConfiguration()
            .setDiscoveryURI("https://www.casserverpac4j.dev/oidc/.well-known/openid-configuration")
            .setClientId("myclient")
            .setSecret("mysecret")
            .setAllowUnsignedIdTokens(true);
        return new Config("http://localhost:8080/callback", new OidcClient(oidc));
    }

    @Bean
    public SecurityFilter protectedFilter(final Config config) {
        return SecurityFilter.build(config, "OidcClient",
            new PathMatcher().includePath("/protected/"));
    }
}

setAllowUnsignedIdTokens(true) is only for the public demo server: remove it for your own provider. Register http://localhost:8080/callback?client_name=OidcClient as the redirect URI. The provider section of the Spring Boot guide covers Keycloak, Google and Microsoft Entra ID.

Look closely at the path: PathMatcher.includePath uses a prefix match. /protected/ covers /protected/index and deeper paths, but not /protected itself or /protected-other. Add rules for those paths if you need them, and keep callback and logout outside the protected prefix.

The SecurityFilter loads the session and dispatches synchronous pac4j work to Reactor's bounded elastic scheduler. The OIDC HTTP calls remain blocking, but they run away from the Netty event loop. There is no scheduler wrapper to add in the application, this is done automatically.

3) Handle the callback and logout

The @ComponentScan above registers the library's CallbackController and LogoutController. Their default paths are /callback and /logout. The callback receives the provider's response, saves the authenticated profile and sends the user back to the requested page.

Let's set the callback and logout options in src/main/resources/application.properties using the default property options:

pac4j.callback.defaultUrl=/
pac4j.callback.renewSession=true
pac4j.logout.defaultUrl=http://localhost:8080/
pac4j.logout.logoutUrlPattern=^http://localhost:8080/$
pac4j.logout.localLogout=true
pac4j.logout.destroySession=true
pac4j.logout.centralLogout=false

The callback renews the session identifier after authentication to protect against session fixation attacks. Logout removes the local profile and destroys the session. The integration waits for these session operations before continuing.

The logout regex only allows the local home URL in the dynamic url parameter. For production, update both the return URL and this allowlist to your public HTTPS origin.

4) Access the authenticated user

With the session loaded and the user authenticated, we can read the profile in a controller. We'll return plain text so attribute values are not interpreted as HTML:

package org.example;

import org.pac4j.core.profile.ProfileManager;
import org.pac4j.oidc.profile.OidcProfile;
import org.pac4j.springframework.context.SpringWebfluxSessionStore;
import org.pac4j.springframework.context.SpringWebfluxWebContext;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebExchange;

@RestController
public class ProtectedController {

    @GetMapping(value = "/", produces = MediaType.TEXT_PLAIN_VALUE)
    public String home() {
        return "Visit /protected/index to sign in, or /logout to sign out.";
    }

    @GetMapping(value = "/protected/index", produces = MediaType.TEXT_PLAIN_VALUE)
    public String index(final ServerWebExchange exchange) {
        final var context = new SpringWebfluxWebContext(exchange);
        final var manager = new ProfileManager(context, new SpringWebfluxSessionStore(exchange));
        final var profile = (OidcProfile) manager.getProfile().orElseThrow();
        return "Hello " + profile.getDisplayName() + " (" + profile.getEmail() + ")";
    }
}

The filter has already loaded the session before the protected controller runs. OidcProfile exposes standard claims and provides access to the ID token and access token. Of course, the available attributes depend on the provider and requested scopes.

5) Enable central logout

The endpoint above is only configured for local logout. To also request logout at the provider (= central logout), set these properties:

pac4j.logout.centralLogout=true
pac4j.logout.defaultUrl=http://localhost:8080/

The provider must expose an end_session_endpoint and allow http://localhost:8080/ as a post-logout redirect URI. Use the public HTTPS URL in production.

6) Run the application

mvn spring-boot:run

Open http://localhost:8080/protected/index, sign in and verify that you return to the protected page. Visiting /logout removes the local profile and session.

If the filter never triggers, check the full request path against the /protected/ prefix. If the provider rejects the redirect URI, include ?client_name=OidcClient in its registration. If /callback or /logout returns 404, check that the component scan registers the library's controllers. If Spring reports duplicate mappings, remove any custom controller left over from the earlier example.

7) Switching to SAML or CAS

The protocol configuration from the SAML guide or CAS guide can be reused with this integration. Add the corresponding module, replace the OidcClient and update the client name in the filter. Adapt the profile type and attributes, and register the callback and logout URLs at the provider. SAML also needs a keystore and metadata exchange.

8) Learn more

Discover more pac4j frameworks and more authentication mechanisms…

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.