spring-bootauthenticationjwtsingle-sign-on

Spring Boot - Two Authentication Methods for some paths


I've got a spring-boot 3.3.2 project. My endpoints act as an oauth2 resource server meaning they expect a JWT-Token and validate it using a given issuer. However, there are some endpoints that should be callable by both JWT and a more or less custom made header validation. I am pretty much stuck and hope to find help here.

This is the web security configuration (it is pretty basic so far):

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authz -> authz
                                .requestMatchers(
                                        "/actuator/health/**",
                                        "/v3/api-docs/**",
                                        "/swagger-ui/**",
                                        "/swagger-ui.html"
                                ).permitAll()
                                .anyRequest().authenticated()
                )
                .oauth2ResourceServer(oauth2 ->
                        oauth2.jwt(Customizer.withDefaults())
                );

        return http.build();
    }

It works like a charm.

What bothers me is that I can only tell spring boot to authenticate using oauth2ResourceServer for the entire configuration and not path-wise.

What I want is like that:

How to do that?

Edit 1: As @ch4mp pointed out: I can use a second filter chain with a security matcher which runs before the original filter chain. Looking like that:

    @Bean
    @Order(1)
    public SecurityFilterChain b2bFilterChain(HttpSecurity http) throws Exception {
        http
                .securityMatcher("/api/b2b/**")
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authz -> authz
                        .anyRequest().permitAll()
                )
                .addFilterBefore(new B2BAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers(
                                "/actuator/health/**",
                                "/v3/api-docs/**",
                                "/swagger-ui/**",
                                "/swagger-ui.html",
                                "/error"
                        ).permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(oauth2 ->
                        oauth2.jwt(Customizer.withDefaults())
                );

        return http.build();
    }

with the filter looking like this:

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        val customToken = request.getHeader("X-Custom-Token");

        if (customToken != null) {
            SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken(
                    "test", "test", Collections.emptyList()
            ));
        }

        filterChain.doFilter(request, response);
    }

The behaviour is the following:

endpoint authentication actual behaviour expected behaviour
/api/b2b custom header works works
/api/b2b sso does not work works
/api/something custom header does not work does not work
/api/something sso works works

So calling that b2b endpoint with the custom header works as intended but when I call the same endpoint with the default configuration, I get blocked with error 403


Solution

  • Create a second SecurityFilterChain.

    Both filter-chains must be ordered (decorated with @Order), and the 1st in order must contain a securityMatcher (based on path, on the Authorization header format, or whatever request property like a cookie being present).

    To use session, the other filter-chain might use oauth2Login or formLogin.

    I created a starter for OIDC clients and resource servers. Depending on prperties and on what is on the classpath, it can auto-configure up to 2 filter-chains:

    I use such a dual filter-chain setup in my OAuth2 BFFs: a stateful Spring Cloud Gateway with oauth2Login (and the TokenRelay= filter on the routes to downstream oauth2ResourceServer), but wich also serves the same OpenAPI spec and actuator endpoint resources as your app does, and does it with a stateless filter-chain very similar to the one you expose above.

    Edit

    Your custom filter logic doesn't look production ready (at all). The business partners should use the OAuth2 client credentials flow rather than such a "custom header". This would be:

    However, as the requests you want to authorize with your custom filter are identified by their path and a custom header, implement a security matcher checking both:

    private static final String B2B_TOKEN_HEADER = "X-Custom-Token";
    private static final String B2B_PATH_PREFIX = "/api/b2b/";
    
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    SecurityFilterChain b2bFilterChain() throws Exception {
    
      http.securityMatchers(filterChain -> {
        filterChain.requestMatchers(
            (HttpServletRequest request) -> StringUtils.hasText(request.getHeader(B2B_TOKEN_HEADER))
                && request.getRequestURI().startsWith(B2B_PATH_PREFIX));
      });
    
      ...
    
      return htt.build();
    }