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:
Order(1)
I included the CORS config at the top of each Security Bean)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:
/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)/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: