How-to: Authenticate using a Single Page Application with PKCE

This guide shows how to configure Spring Authorization Server to support a Single Page Application (SPA) with Proof Key for Code Exchange (PKCE). The purpose of this guide is to demonstrate how to support a public client and require PKCE for client authentication.

Spring Authorization Server will not issue refresh tokens for a public client. We recommend the backend for frontend (BFF) pattern as an alternative to exposing a public client. See gh-297 for more information.

Enable CORS

A SPA consists of static resources that can be deployed in a variety of ways. It can be deployed separately from the backend such as with a CDN or separate web server, or it can be deployed along side the backend using Spring Boot.

When a SPA is hosted under a different domain, Cross Origin Resource Sharing (CORS) can be used to allow the application to communicate with the backend.

For example, if you have an Angular dev server running locally on port 4200, you can define a CorsConfigurationSource @Bean and configure Spring Security to allow pre-flight requests using the cors() DSL as in the following example:

Enable CORS
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
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.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	@Order(1)
	public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
			throws Exception {
		OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
				OAuth2AuthorizationServerConfigurer.authorizationServer();

		http
			.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
			.with(authorizationServerConfigurer, (authorizationServer) ->
				authorizationServer
					.oidc(Customizer.withDefaults())	// Enable OpenID Connect 1.0
			)
			.authorizeHttpRequests((authorize) ->
				authorize
					.anyRequest().authenticated()
			)
			// Redirect to the login page when not authenticated from the
			// authorization endpoint
			.exceptionHandling((exceptions) -> exceptions
				.defaultAuthenticationEntryPointFor(
					new LoginUrlAuthenticationEntryPoint("/login"),
					new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
				)
			);

		return http.cors(Customizer.withDefaults()).build();
	}

	@Bean
	@Order(2)
	public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
			throws Exception {
		http
			.authorizeHttpRequests((authorize) -> authorize
				.anyRequest().authenticated()
			)
			// Form login handles the redirect to the login page from the
			// authorization server filter chain
			.formLogin(Customizer.withDefaults());

		return http.cors(Customizer.withDefaults()).build();
	}

	@Bean
	public CorsConfigurationSource corsConfigurationSource() {
		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		CorsConfiguration config = new CorsConfiguration();
		config.addAllowedHeader("*");
		config.addAllowedMethod("*");
		config.addAllowedOrigin("http://127.0.0.1:4200");
		config.setAllowCredentials(true);
		source.registerCorsConfiguration("/**", config);
		return source;
	}

}
Click on the "Expand folded text" icon in the code sample above to display the full example.

Configure a Public Client

A SPA cannot securely store credentials and therefore must be treated as a public client. Public clients should be required to use Proof Key for Code Exchange (PKCE).

Continuing the earlier example, you can configure Spring Authorization Server to support a public client using the Client Authentication Method none and require PKCE as in the following example:

  • Yaml

  • Java

spring:
  security:
    oauth2:
      authorizationserver:
        client:
          public-client:
            registration:
              client-id: "public-client"
              client-authentication-methods:
                - "none"
              authorization-grant-types:
                - "authorization_code"
              redirect-uris:
                - "http://127.0.0.1:4200"
              scopes:
                - "openid"
                - "profile"
            require-authorization-consent: true
            require-proof-key: true
@Bean
public RegisteredClientRepository registeredClientRepository() {
	RegisteredClient publicClient = RegisteredClient.withId(UUID.randomUUID().toString())
		.clientId("public-client")
		.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
		.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
		.redirectUri("http://127.0.0.1:4200")
		.scope(OidcScopes.OPENID)
		.scope(OidcScopes.PROFILE)
		.clientSettings(ClientSettings.builder()
			.requireAuthorizationConsent(true)
			.requireProofKey(true)
			.build()
		)
		.build();

	return new InMemoryRegisteredClientRepository(publicClient);
}
The requireProofKey setting is important to prevent the PKCE Downgrade Attack.

Authenticate with the Client

Once the server is configured to support a public client, a common question is: How do I authenticate the client and get an access token? The short answer is: The same way you would with any other client.

A SPA is a browser-based application and therefore uses the same redirection-based flow as any other client. This question is usually related to an expectation that authentication can be performed via a REST API, which is not the case with OAuth2.

A more detailed answer requires an understanding of the flow(s) involved in OAuth2 and OpenID Connect, in this case the Authorization Code flow. The steps of the Authorization Code flow are as follows:

  1. The client initiates an OAuth2 request via a redirect to the Authorization Endpoint. For a public client, this step includes generating the code_verifier and calculating the code_challenge, which is then sent as a query parameter.

  2. If the user is not authenticated, the authorization server will redirect to the login page. After authentication, the user is redirected back to the Authorization Endpoint again.

  3. If the user has not consented to the requested scope(s) and consent is required, the consent page is displayed.

  4. Once the user has consented, the authorization server generates an authorization_code and redirects back to the client via the redirect_uri.

  5. The client obtains the authorization_code via a query parameter and performs a request to the Token Endpoint. For a public client, this step includes sending the code_verifier parameter instead of credentials for authentication.

As you can see, the flow is fairly involved and this overview only scratches the surface.

It is recommended that you use a robust client-side library supported by your single-page app framework to handle the Authorization Code flow.