In modern web applications, particularly within microservice architectures, it’s common to have external services and identity providers interacting with your application or service. Did you know that you can adjust your Spring Boot application so that you can add or remove support for those parties without much hassle? In this article I want to explore the idea and see what can we do to build around it.

Why multiple Authentication Providers? If your application needs to interact with different identity providers or use different user pools, each having its own set of credentials and security configurations, handling this with single authentication provider can become cumbersome and less maintenable. Application with multiple authentication providers can authenticate tokens issued by different issuers seamlessly, making the system more modular and scalable.

Let’s say you have a service in your landscape, and the service can expose secure authentication endpoints via identity providers such as AWS Cognito, Azure AD, and more. However, times change, your business grows and you also want to expose your service to external parties, where you want to enable exposing endpoints to your partners and let them use your service.

For the first part of the equation, process is rather simple and well known, you can keep your configuration in the application.yml file (or files if you have multiple environments). However for the second part some manual intervention is required.

Let’s see, how we can set up the first part, where your service is used internally and then we will build later on from that.

@Configuration
public class InternalWebSecurityConfig {

    @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
    private String jwkSetUri;

    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
    }

    @Bean
    public SecurityFilterChain internalFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/internal/**")
            .authorizeHttpRequests((auth) -> auth
                .requestMatchers("/api/internal/**")
                .authenticated()
                .anyRequest().permitAll())
            .csrf(CsrfConfigurer::disable)
            .sessionManagement(sessionManagement -> sessionManagement
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer(oauth2ResourceServer ->
                                        oauth2ResourceServer
                                                .jwt(jwt -> jwt.decoder(jwtDecoder())));

        return http.build();
    }
}

InternalWebSecurityConfig is dedicated for protecting internal endpoints, and the setup is quite simple, any request going to the /api/internal/** needs to be authenticated, everything else is permitted. Looking at the InternalWebSecurityConfig class it indicates that it should have a yml with cognito user pool set up, something like:

spring:
    security:
        oauth2:
            resourceserver:
                jwt:
                    jwk-set-uri: https://cognito-idp.eu-west-1.amazonaws.com/eu-west-id/.well-known/jwks.json

NOTE: This can be shortened more if you are using one token issuer and spring boot auto configuration, the entire class can be reduced to exposing one bean:

    @Bean
    public SecurityFilterChain internalFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/internal/**")
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/internal/**")
                .authenticated()
                .anyRequest().permitAll())
            .csrf(CsrfConfigurer::disable)
            .sessionManagement(sessionManagement -> sessionManagement
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults()));
        return http.build();
    }

And this should work, anything coming to the /api/internal/** endpoints needs to have a token issued by our cognito defined in the configuration.

Now where things start to get interesting is when you want to expose your service to the external parties, for this usage I would suggest creating another WebSecurityConfiguration, to have the clear intention to separate the endpoints for internal and external purposes, it can be named something like ExternalWebSecurityConfig, and it’s going to be a bit different the the first one.

ExternalWebSecurityConfig relies on UserPoolConfiguration entities, to get the information in order to be able to authenticate the requests coming in with the authentication tokens corresponding to the given user pool. UserPoolConfiguration class should at least have region and userPoolId attributes in order for this configuration to work. In the shown example Amazon Cognito is used, that’s the reason for the base url string COGNITO_BASE_URL, and the information about the user pool is fetched from the UserPoolConfiguration repository. For simplicity sake, I’ve added everything in this example class, in real world scenario you would split it up in appropriate classes, use database, etc.

@Configuration
public class ExternalWebSecurityConfig {

    private final String COGNITO_BASE_URL = "https://cognito-idp.%s.amazonaws.com/%s";

    @Bean
    public SecurityFilterChain externalFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/external/**")
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/external/**")
                .authenticated()
                .anyRequest().permitAll()
            ).csrf(CsrfConfigurer::disable)
            .sessionManagement(sessionManagement -> sessionManagement
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer(oauth2ResourceServer -> {
                oauth2ResourceServer.authenticationManagerResolver(getAuthenticationManagerResolver());
            });
        return http.build();
    }

    public JwtIssuerAuthenticationManagerResolver getAuthenticationManagerResolver() {
        Map<String, AuthenticationManager> authenticationManagers = new HashMap<>();

        List<UserPoolConfiguration> userPoolConfigurationList = new UserPoolConfigurationRepository().findAll();

        userPoolConfigurationList.forEach(userPoolConfiguration -> {
            String poolConfig = String.format(COGNITO_BASE_URL, 
                                              userPoolConfiguration.region(), 
                                              userPoolConfiguration.userPoolId());

            addManager(authenticationManagers, poolConfig);
        });

        return new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);
    }

    public void addManager(Map<String, AuthenticationManager> authenticationManagers, String issuer) {
        JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider(JwtDecoders.fromOidcIssuerLocation(issuer));
        authenticationManagers.put(issuer, authenticationProvider::authenticate);
    }

    public record UserPoolConfiguration(String region, String userPoolId) {

    }

    public record UserPoolConfigurationRepository() {

        List<UserPoolConfiguration> findAll() {
            return List.of(new UserPoolConfiguration("eu-west-1", "eu-west-1_id"), 
                           new UserPoolConfiguration("eu-central-1", "eu-central-1_id"));
        }
    }

}

This config will make sure that during the application startup, information about the user pools is picked up from the database and loaded into the application. ExternalWebSecurityConfig.getAuthenticationManagerResolver() method will fetch the information about the user pools, and will create the appropriate authentication manager.

Now we have a configuration that will serve the described purpose. Your application will have internal and external endpoints, where configuration for internal endpoints is relying on application configuration, which will not be subjected to frequent changes, and the configuration for external endpoints will rely on database, where more frequent changes are possible.

However, this configuration will only load what’s in the database during application startup. What if we want to crank this up a notch? Can we load or remove the information for the external configuration during application runtime? We will explore that in the next part of the series.

Full example described in this article is available on my GitHub profile.

Appendix

If you are still using Spring Security that predates 5.4, configuring filter chain is bit different. You’ll need to extend WebSecurityConfigurerAdapter and override configure method.

@Configuration
@Order(4)
public class InternalWebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .requestMatchers()
            .antMatchers("/api/internal/**")
            .and()
            .authorizeRequests()
            .anyRequest().authenticated()
            .and()
            // ... whatever else you need to set
            .oauth2ResourceServer()
            .jwt();
    }
}

This configuration implies that yml file contains the value for jwk-set-uri, which was mentioned earlier. Similarly ExternalWebSecurityConfig needs to be adjusted as well.

@Configuration
@Order(5)
public class ExternalWebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final String COGNITO_BASE_URL = "https://cognito-idp.%s.amazonaws.com/%s";

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .requestMatchers()
            .antMatchers("/api/external/**")
            .and()
            .authorizeRequests()
            .anyRequest().authenticated()
            .and()
            // ... whatever else you need to set
            .oauth2ResourceServer(oauth2ResourceServer -> {
                oauth2ResourceServer.authenticationManagerResolver(getAuthenticationManagerResolver());
            });
    }

    // Rest of the code is the same as in previous example for `ExternalWebSecurityConfig`
}