I've lost my mind trying things here. This is only my latest attempt, I've basically been in "add" mode.

The documentation suggests this should be enough.

https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html

// © Copyright 2024 Caleb Cushing
// SPDX-License-Identifier: AGPL-3.0-or-later

package com.xenoterracide.controller.authn;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.actuate.web.exchanges.HttpExchangeRepository;
import org.springframework.boot.actuate.web.exchanges.InMemoryHttpExchangeRepository;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@EnableWebSecurity
@SpringBootApplication
public class ResourceServer {

  ResourceServer() {}

  public static void main(String[] args) {
    SpringApplication.run(ResourceServer.class, args);
  }

  @Bean
  @Order(0)
  SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
      .securityMatcher("/**")
      .cors(cors -> cors.configurationSource(this.corsConfigurationSource()))
      .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
      .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
      .httpBasic(c -> c.disable())
      .formLogin(f -> f.disable());
    return http.build();
  }

  @Bean
  WebSecurityCustomizer webSecurityCustomizer() {
    return web -> web.debug(true);
  }

  @Bean
  HttpExchangeRepository httpExchangeRepository() {
    return new InMemoryHttpExchangeRepository();
  }

  @Bean
  CorsConfigurationSource corsConfigurationSource() {
    var cors = new CorsConfiguration();
    cors.addAllowedOrigin("http://localhost:3000");
    cors.addAllowedMethod("*");
    cors.addAllowedHeader("*");
    var source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", cors);
    return source;
  }

  @Bean
  WebMvcConfigurer corsConfigurer() {
    return new WebMvcConfigurer() {
      @Override
      public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/external").allowedOrigins("http://localhost:3000");
      }
    };
  }

  @RestController
  static class OidcTestController {

    private final Logger log = LogManager.getLogger(this.getClass());

    @CrossOrigin(originPatterns = "*")
    @GetMapping("/api/external")
    @NonNull
    String index(@Nullable Authentication details) {
      this.log.info("{}", details);
      var name = details != null ? details.getName() : "world";
      return "Hello, " + name;
    }
  }
}
 INFO 743291 - Spring Security Debugger                                     : 

************************************************************

Request received for GET '/api/external':

org.apache.catalina.connector.RequestFacade@20882dd6

servletPath:/api/external
pathInfo:null
headers: 
host: localhost:8080
user-agent: curl/8.6.0
accept: */*


Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextHolderFilter
  HeaderWriterFilter
  CorsFilter
  CsrfFilter
  LogoutFilter
  BearerTokenAuthenticationFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  ExceptionTranslationFilter
  AuthorizationFilter
]


************************************************************


DEBUG 743291 - o.spri.secu.web.FilterChainProxy                             : Securing GET /api/external
DEBUG 743291 - o.spri.secu.web.FilterChainProxy                             : Secured GET /api/external
DEBUG 743291 - o.spri.web.serv.DispatcherServlet                            : GET "/api/external", parameters={}
DEBUG 743291 - o.spri.web.serv.mvc.meth.anno.RequestMappingHandlerMapping   : Mapped to com.xenoterracide.controller.authn.ResourceServer$OidcTestController#index(Authentication)
DEBUG 743291 - o.spri.secu.web.auth.AnonymousAuthenticationFilter           : Set SecurityContextHolder to anonymous SecurityContext
 INFO 743291 - c.xeno.cont.auth.Reso.OidcTestController                     : null
DEBUG 743291 - ri.web.serv.mvc.meth.anno.RequestResponseBodyMethodProcessor : Using 'text/plain', given [*/*] and supported [text/plain, */*, application/json, application/*+json]
DEBUG 743291 - ri.web.serv.mvc.meth.anno.RequestResponseBodyMethodProcessor : Writing ["Hello, world"]
DEBUG 743291 - o.spri.web.serv.DispatcherServlet                            : Completed 200 OK
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /api/external HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.6.0
> Accept: */*
> 
< HTTP/1.1 200 
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 12
< Date: Fri, 05 Apr 2024 10:52:42 GMT
< 
{ [12 bytes data]
* Connection #0 to host localhost left intact
Hello, world

Comment From: xenoterracide

So I finally tracked down my issue by stepping through the filter chain with the debugger... hrm... I opened another bug https://github.com/spring-projects/spring-framework/issues/32580 this seems like it's a logging issue as much as anything. I don't know how I was supposed to figure out the problem was that spring was being "smart" about header output. Leaving this open in case these docs could be improved, though I'm going to speculate I would have missed it unless it was loud. I have also asked for improvements to the docs in the gs guide https://github.com/spring-guides/gs-rest-service-cors/issues/39

Comment From: StevenPG

Is there an intermediate fix for something like actuator that you've found? I've been stuck on this for hours and had to create an incredibly janky implementation for a similar issue with spring-cloud-gateway.

Comment From: xenoterracide

Uh, I'm not sure if I understand the question/problem you're having. I tracked mine down to a header that needed to be sent in the request that is normally sent by a web browser, but it's not generally sent from other clients unless you tell it to.

Comment From: StevenPG

Sorry, I think I'm in the same boat. I have an empty project with spring-web-starter, spring-security-starter and a SecurityFilterChain. Following the documentation to the letter and can't make any progress into actually disabling cors successfully for RestController endpoints or actuator endpoints.

I also just think there must be something missing from the documentation, and can't figure out a way around it.

Comment From: xenoterracide

Sounds like the inverse of my problem which was getting the Cors headers to actually trigger

Comment From: sjohnr

Hi @xenoterracide, thanks for reaching out and sorry you had trouble with this. I also apologize for the delay in responding, but I'm going through our issue tracker now to find issues that were missed.

Leaving this open in case these docs could be improved

I feel the docs you linked to provide quite good examples and also link to Spring Framework's docs on the subject. Spring doesn't generally provide how-to guides on using web technologies and specifications themselves, so I don't think the information that would have helped is appropriate for our docs in any case.

I'm going to close this issue with the above explanation.