Search code examples
javaspringspring-bootspring-securitycors

How to properly configure CORS on a Spring Boot Auth Server?


I got a basic Vue.js application for the authentication at our Spring Boot auth server. What follows is that code that does the full log-in (redirect to /login, request to the token endpoint, etc.):

<template>
    <div class="h-screen w-screen flex flex-col items-center justify-center">
        <p class="text-center mb-5">You need to be logged in<br>to access the admin dashboard.</p>
        <button @click.prevent="startAuthFlow" class="bg-blue-500 text-white h-8 w-36 rounded shadow">Login</button>
    </div>
</template>

<script setup>
import { config 
} from "./../common/config.js";
import { parseQueryString, generateRandomString } from "./../utils/authHelpers.js";
import router from './../router';
import { useAuthStore } from './../stores/auth.js';

const auth = useAuthStore();

if (auth.access_token) {
    router.push({ name: 'home', replace: true });
}

const startAuthFlow = () => {
    console.log("Starting auth flow...");

    // Create and store a random "state" value
    var state = generateRandomString();
    localStorage.setItem("pkce_state", state);
    console.log(state);

    // Build the authorization URL
    var url = config.authorization_endpoint
        + "?response_type=code"
        + "&client_id=" + encodeURIComponent(config.client_id)
        + "&state=" + encodeURIComponent(state)
        + "&redirect_uri=" + encodeURIComponent(config.redirect_uri);

    // Redirect to the authorization server
    window.location = url;
};

// Handle the redirect back from the authorization server and
// get an access token from the token endpoint
var q = parseQueryString(window.location.search.substring(1));

// Check if the server returned an error string
if (q.error) {
    alert("Error returned from authorization server: " + q.error);
}

// If the server returned an authorization code, attempt to exchange it for an access token
if (q.code) {
    // Verify state matches what we set at the beginning
    if (localStorage.getItem("pkce_state") != q.state) {
        alert("Invalid state");
    } else {
        // Base64 encode client credentials
        const base64Credentials = btoa(config.client_id + ':' + config.client_secret);

        // Build the token URL
        var url = config.token_endpoint
            + "?grant_type=authorization_code"
            + "&client_id=" + encodeURIComponent(config.client_id)
            + "&code=" + q.code
            + "&redirect_uri=" + encodeURIComponent(config.redirect_uri);

        // Send POST request to token endpoint to retrieve access token
        fetch(url, {
            method: "POST",
            headers: {
                "Authorization": "Basic " + base64Credentials,
                'Content-Type': 'application/x-www-form-urlencoded',
            }
        }).then(response => response.json())
            .then(result => {
                console.log(result);
                // Extracting tokens from result
                const { access_token, refresh_token } = result;

                // Save login to pinia and tokens to cookies
                auth.login({ access_token, refresh_token, user: null });

                router.push({ name: 'home', replace: true });
            })
            .catch(error => console.log('error', error));
    }

    // Clean up local storage
    localStorage.removeItem("pkce_state");
}

</script>

When using a browser with deactivated security features (does not check CORS), this works perfectly fine. Both the log-in as well as other requests.

Now when using a normal browser, the redirect to /login works perfectly fine. But when sending a request to the token endpoint the following error appears:

Access to fetch at 'http://localhost:8081/oauth2/token?grant_type=authorization_code&client_id=core-server&code=Ps19gIUUePThDLr15xX0U-UWEMd0HgHfyAOCcZjGmfXUqED80GOMLBykuldNrL7k23dxEydcP49hX_kGigKsZjcFCTS93xU7kwdwAIm6-eIcIk_ayN5i0mZPLeg_bDYx&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2F' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

In the CORS-deactivated browser, I get the following headers back:

Vary: Origin 
Vary: Access-Control-Request-Method 
Vary: Access-Control-Request-Headers

What follows is my SecurityConfig file as well as the CorsConfig:

@Configuration
public class
CorsConfig {

    private static final Logger logger = LoggerFactory.getLogger(CorsConfig.class);

    /**
     * Cors configuration
     */
    @Bean(name="corsConfigurationSource")
    CorsConfigurationSource corsConfigurationSource() {

        logger.info("Creating corsConfigurationSource bean");

        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of(
                "http://localhost:5173",
                "http://192.168.2.144:5173"
        ));

        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        configuration.setAllowCredentials(true);
        configuration.setAllowedHeaders(List.of(
                "Authorization",
                "Content-Type",
                "Accept",
                "Origin",
                "X-Requested-With"
        ));
        configuration.setExposedHeaders(List.of(
                "Cache-Control",
                "Content-Language",
                "Content-Type",
                "Expires",
                "Last-Modified",
                "Pragma"
        ));
        configuration.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

and

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // claim names used in the bearer token
    private static final String ROLES_CLAIM = "user-authorities";
    private static final String SCOPES_CLAIM = "scope";

    private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class);


    @Bean
    @Order(1)
    public CorsFilter corsFilter(CorsConfigurationSource corsConfigurationSource) {
        logger.info("Creating corsFilter bean");
        return new CorsFilter(corsConfigurationSource);
    }


    /**
     * Configures the authorization server endpoints.
     */
    @Bean
    @Order(2)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, RegisteredClientRepository clientRepository) throws Exception {

        logger.info("Creating authorizationServerSecurityFilterChain bean");

        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .registeredClientRepository(clientRepository) // autowired from ClientConfig.java
                .oidc(Customizer.withDefaults());

        http.exceptionHandling((exceptions) -> exceptions
            .defaultAuthenticationEntryPointFor(
                new LoginUrlAuthenticationEntryPoint("/login"),
                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
            )
        );

        http.oauth2ResourceServer((resourceServer) -> resourceServer
                .jwt(Customizer.withDefaults()));

        http.csrf(AbstractHttpConfigurer::disable);

        return http.build();
    }


    /**
     * Secures pages used to log in, log out, register etc.
     * Sets custom login menu.
     */
    @Bean
    @Order(3)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher(new NegatedRequestMatcher(new AntPathRequestMatcher("/admin/**")));

        logger.info("Creating defaultSecurityFilterChain bean");

        http.authorizeHttpRequests((authorize) ->
                authorize
                        .requestMatchers(new AntPathRequestMatcher("/register")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/recover/**")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/error/**")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/css/**")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/js/**")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher("/favicon.ico")).permitAll()
                        .anyRequest().authenticated());

        http.oauth2ResourceServer((resourceServer) -> resourceServer
                .jwt(Customizer.withDefaults()));

        // set custom login form
        http.formLogin(form -> {
            form.loginPage("/login");
            form.permitAll();
        });

        http.logout(conf -> {
            // default logout url
            conf.logoutSuccessHandler(logoutSuccessHandler());
        });

        // Temp disable CSRF
        http.csrf(AbstractHttpConfigurer::disable);
        http.cors(AbstractHttpConfigurer::disable);

        return http.build();
    }


    /**
     * Secures admin endpoints with a bearer token. Does not use session authentication.
     */
    @Bean
    @Order(4)
    public SecurityFilterChain adminResourceFilterChain(HttpSecurity http) throws Exception {

        logger.info("Creating adminResourceFilterChain bean");

        // handle out custom endpoints in this filter chain
        http.authorizeHttpRequests((authorize) ->
                authorize
                        .requestMatchers(new AntPathRequestMatcher("/admin/**")).hasRole("ADMIN")
                        .anyRequest().authenticated());

        http.sessionManagement(conf -> conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        http.oauth2ResourceServer((resourceServer) -> resourceServer
                .jwt(Customizer.withDefaults()));

        // Temp disable CSRF
        http.csrf(AbstractHttpConfigurer::disable);
        http.cors(AbstractHttpConfigurer::disable);


        return http.build();
    }

// ...

I tried lots of different approaches:

  • Changed the filter chains (e.g. instead of Order(1) I included the CORS config at the top of each Security Bean)
  • Went ahead and implemented the CORS Tutorial one by one.
  • Tried to set the request headers manually (which isn't a solution in the long-term).
  • Tried pattern matching to allow every incoming request temporarily (didn't work, so it's apparently an issue with the CORS Config in itself)

Solution

  • Single Page Applications (Angular, React, Vue.js, etc.), as well as mobile applications, should not be OAuth2 client. Such clients are "public" clients and this is now discouraged.

    Your Vue app should be secured with sessions on a BFF with OAuth2 login and something to replace session cookie with an authorization header containing an access token. The easiest way to achieve that is probably using spring-cloud-gateway with the TokenRelay filter and spring-boot-starter-oauth2-client with oauth2Login

    I wrote a tutorial for that on Baeldung which contains sample implementations for Angular, React (Next.js) and Vue.

    In addition the securing the frontend with sessions, the gateway can remove the need for most of CORS configuration: from the browser point of view, all requests routed through the gateway have the same origin (the gateway).

    In the tutorial linked:

    • all requests to the gateway (lets say https://localhost:8080) with a path starting with /ui/ are routed to the what serves the UI assets (something like https://localhost:4200/ui/ in the case of an Angular dev server, but could be a Vue dev server, a NGINX instance containing anything, or whatever)
    • all requests tho the gateway with a path starting with /bff/v1/ are routed to a resource server (something like https://localhost:7084/)

    In the configuration above, if the user points its browser to https://localhost:8080/ui/ and if the SPA is configured to send REST requests to https://localhost:8080/bff/v1/**, then, from the browser perspective, both the requests to the UI and to the API have https://localhost:8080 as origin.

    Still in this tutorial, the authorization server is not routed through the gateway and needs some CORS configuration to allow requests with the gateway as origin. The reason is that, most frequently, when using OAuth2 you are interested in Single Sign On: share the same authorization server across different applications to save the user the need for authenticating several times when using the same browser, which requires to be using the same cookie and as a consequence the same host & port (the authorization server is contacted by the browser on let's say https://oidc.c4-soft.com) whatever the BFF instances (instead of something like https://localhost:8080/auth for instance in the case of the tutorial BFF).

    All that to write that if using a gateway as OAuth2 confidential client, you can remove the need for CORS configuration:

    • from resource servers (if you route the UI and REST requests through the gateway)
    • from the authorization server (if you route the UI and the authorization server through the gateway, but at the price of loosing SSO)