Browse Source

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

Closes gh-539
Steve Riesenberg 2 năm trước cách đây
mục cha
commit
321080fbc2

+ 56 - 0
docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/ClientConfig.java

@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package sample.pkce;
+
+import java.util.UUID;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+
+@Configuration
+public class ClientConfig {
+
+	// tag::client[]
+	@Bean
+	public RegisteredClientRepository registeredClientRepository() {
+		// @formatter:off
+		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();
+		// @formatter:on
+
+		return new InMemoryRegisteredClientRepository(publicClient);
+	}
+	// end::client[]
+
+}

+ 95 - 0
docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/SecurityConfig.java

@@ -0,0 +1,95 @@
+/*
+ * Copyright 2020-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package sample.pkce;
+
+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.configuration.OAuth2AuthorizationServerConfiguration;
+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 {
+		// @fold:on
+		OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
+		http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
+			.oidc(Customizer.withDefaults());	// Enable OpenID Connect 1.0
+		// @formatter:off
+		http
+			// Redirect to the login page when not authenticated from the
+			// authorization endpoint
+			.exceptionHandling((exceptions) -> exceptions
+				.defaultAuthenticationEntryPointFor(
+					new LoginUrlAuthenticationEntryPoint("/login"),
+					new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
+				)
+			)
+			// Accept access tokens for User Info and/or Client Registration
+			.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
+		// @formatter:on
+
+		// @fold:off
+		return http.cors(Customizer.withDefaults()).build();
+	}
+
+	@Bean
+	@Order(2)
+	public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
+			throws Exception {
+		// @fold:on
+		// @formatter:off
+		http
+			.authorizeHttpRequests((authorize) -> authorize
+				.anyRequest().authenticated()
+			)
+			// Form login handles the redirect to the login page from the
+			// authorization server filter chain
+			.formLogin(Customizer.withDefaults());
+		// @formatter:on
+
+		// @fold:off
+		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;
+	}
+
+}

+ 19 - 0
docs/src/docs/asciidoc/examples/src/main/java/sample/pkce/application.yml

@@ -0,0 +1,19 @@
+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

+ 62 - 5
docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java

@@ -31,6 +31,7 @@ import org.springframework.http.MediaType;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.MvcResult;
@@ -85,6 +86,17 @@ public class AuthorizationCodeGrantFlow {
 	 * @return The state parameter for submitting consent for authorization
 	 */
 	public String authorize(RegisteredClient registeredClient) throws Exception {
+		return authorize(registeredClient, null);
+	}
+
+	/**
+	 * Perform the authorization request and obtain a state parameter.
+	 *
+	 * @param registeredClient The registered client
+	 * @param additionalParameters Additional parameters for the request
+	 * @return The state parameter for submitting consent for authorization
+	 */
+	public String authorize(RegisteredClient registeredClient, MultiValueMap<String, String> additionalParameters) throws Exception {
 		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
 		parameters.set(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue());
 		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
@@ -92,13 +104,18 @@ public class AuthorizationCodeGrantFlow {
 		parameters.set(OAuth2ParameterNames.SCOPE,
 				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
 		parameters.set(OAuth2ParameterNames.STATE, "state");
+		if (additionalParameters != null) {
+			parameters.addAll(additionalParameters);
+		}
 
+		// @formatter:off
 		MvcResult mvcResult = this.mockMvc.perform(get("/oauth2/authorize")
 				.params(parameters)
 				.with(user(this.username).roles("USER")))
 				.andExpect(status().isOk())
 				.andExpect(header().string("content-type", containsString(MediaType.TEXT_HTML_VALUE)))
 				.andReturn();
+		// @formatter:on
 		String responseHtml = mvcResult.getResponse().getContentAsString();
 		Matcher matcher = HIDDEN_STATE_INPUT_PATTERN.matcher(responseHtml);
 
@@ -120,14 +137,16 @@ public class AuthorizationCodeGrantFlow {
 			parameters.add(OAuth2ParameterNames.SCOPE, scope);
 		}
 
+		// @formatter:off
 		MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/authorize")
 				.params(parameters)
 				.with(user(this.username).roles("USER")))
 				.andExpect(status().is3xxRedirection())
 				.andReturn();
+		// @formatter:on
 		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
 		assertThat(redirectedUrl).isNotNull();
-		assertThat(redirectedUrl).matches("http://127.0.0.1:8080/\\S+\\?code=.{15,}&state=state");
+		assertThat(redirectedUrl).matches("\\S+\\?code=.{15,}&state=state");
 
 		String locationHeader = URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8.name());
 		UriComponents uriComponents = UriComponentsBuilder.fromUriString(locationHeader).build();
@@ -143,29 +162,67 @@ public class AuthorizationCodeGrantFlow {
 	 * @return The token response
 	 */
 	public Map<String, Object> getTokenResponse(RegisteredClient registeredClient, String authorizationCode) throws Exception {
+		return getTokenResponse(registeredClient, authorizationCode, null);
+	}
+
+	/**
+	 * Exchange an authorization code for an access token.
+	 *
+	 * @param registeredClient The registered client
+	 * @param authorizationCode The authorization code obtained from the authorization request
+	 * @param additionalParameters Additional parameters for the request
+	 * @return The token response
+	 */
+	public Map<String, Object> getTokenResponse(RegisteredClient registeredClient, String authorizationCode, MultiValueMap<String, String> additionalParameters) throws Exception {
 		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
 		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
 		parameters.set(OAuth2ParameterNames.CODE, authorizationCode);
 		parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next());
+		if (additionalParameters != null) {
+			parameters.addAll(additionalParameters);
+		}
 
-		HttpHeaders basicAuth = new HttpHeaders();
-		basicAuth.setBasicAuth(registeredClient.getClientId(), "secret");
+		boolean publicClient = (registeredClient.getClientSecret() == null);
+		HttpHeaders headers = new HttpHeaders();
+		if (!publicClient) {
+			headers.setBasicAuth(registeredClient.getClientId(),
+					registeredClient.getClientSecret().replace("{noop}", ""));
+		}
 
+		// @formatter:off
 		MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/token")
 				.params(parameters)
-				.headers(basicAuth))
+				.headers(headers))
 				.andExpect(status().isOk())
 				.andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.APPLICATION_JSON_VALUE)))
 				.andExpect(jsonPath("$.access_token").isNotEmpty())
 				.andExpect(jsonPath("$.token_type").isNotEmpty())
 				.andExpect(jsonPath("$.expires_in").isNotEmpty())
-				.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+				.andExpect(publicClient
+						? jsonPath("$.refresh_token").doesNotExist()
+						: jsonPath("$.refresh_token").isNotEmpty()
+				)
 				.andExpect(jsonPath("$.scope").isNotEmpty())
 				.andExpect(jsonPath("$.id_token").isNotEmpty())
 				.andReturn();
+		// @formatter:on
 
 		ObjectMapper objectMapper = new ObjectMapper();
 		String responseJson = mvcResult.getResponse().getContentAsString();
 		return objectMapper.readValue(responseJson, TOKEN_RESPONSE_TYPE_REFERENCE);
 	}
+
+	public static MultiValueMap<String, String> withCodeChallenge() {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(PkceParameterNames.CODE_CHALLENGE, "BqZZ8pTVLsiA3t3tDOys2flJTSH7LoL3Pp5ZqM_YOnE");
+		parameters.set(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
+		return parameters;
+	}
+
+	public static MultiValueMap<String, String> withCodeVerifier() {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(PkceParameterNames.CODE_VERIFIER, "yZ6eB-lEB4BBhIzqoDPqXTTATC0Vkgov7qDF8ar2qT4");
+		return parameters;
+	}
 }

+ 87 - 0
docs/src/docs/asciidoc/examples/src/test/java/sample/pkce/PublicClientTests.java

@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package sample.pkce;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import sample.AuthorizationCodeGrantFlow;
+import sample.test.SpringTestContext;
+import sample.test.SpringTestContextExtension;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static sample.AuthorizationCodeGrantFlow.withCodeChallenge;
+import static sample.AuthorizationCodeGrantFlow.withCodeVerifier;
+
+/**
+ * @author Steve Riesenberg
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class PublicClientTests {
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	private MockMvc mockMvc;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Test
+	public void oidcLoginWhenPublicClientThenSuccess() throws Exception {
+		this.spring.register(AuthorizationServerConfig.class).autowire();
+
+		RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("public-client");
+		assertThat(registeredClient).isNotNull();
+
+		AuthorizationCodeGrantFlow authorizationCodeGrantFlow = new AuthorizationCodeGrantFlow(this.mockMvc);
+		authorizationCodeGrantFlow.setUsername("user");
+		authorizationCodeGrantFlow.addScope(OidcScopes.OPENID);
+		authorizationCodeGrantFlow.addScope(OidcScopes.PROFILE);
+
+		String state = authorizationCodeGrantFlow.authorize(registeredClient, withCodeChallenge());
+		assertThat(state).isNotNull();
+
+		String authorizationCode = authorizationCodeGrantFlow.submitConsent(registeredClient, state);
+		assertThat(authorizationCode).isNotNull();
+
+		Map<String, Object> tokenResponse = authorizationCodeGrantFlow.getTokenResponse(registeredClient,
+				authorizationCode, withCodeVerifier());
+		assertThat(tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN)).isNotNull();
+		// Note: Refresh tokens are not issued to public clients
+		assertThat(tokenResponse.get(OAuth2ParameterNames.REFRESH_TOKEN)).isNull();
+		assertThat(tokenResponse.get(OidcParameterNames.ID_TOKEN)).isNotNull();
+	}
+
+	@EnableWebSecurity
+	@EnableAutoConfiguration
+	@ComponentScan
+	static class AuthorizationServerConfig {
+
+	}
+
+}

+ 77 - 0
docs/src/docs/asciidoc/guides/how-to-pkce.adoc

@@ -0,0 +1,77 @@
+[[how-to-pkce]]
+= How-to: Authenticate using a Single Page Application with PKCE
+:index-link: ../how-to.html
+:docs-dir: ..
+:examples-dir: {docs-dir}/examples
+
+This guide shows how to configure xref:{docs-dir}/index.adoc#top[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.
+
+NOTE: 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 https://github.com/spring-projects/spring-authorization-server/issues/297#issue-896744390[gh-297] for more information.
+
+* <<enable-cors>>
+* <<configure-public-client>>
+* <<authenticate-with-client>>
+
+[[enable-cors]]
+== 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-configuration]]
+.Enable CORS
+[source,java]
+----
+include::{examples-dir}/src/main/java/sample/pkce/SecurityConfig.java[]
+----
+
+TIP: Click on the "Expand folded text" icon in the code sample above to display the full example.
+
+[[configure-public-client]]
+== Configure a Public Client
+
+A SPA cannot securely store credentials and therefore must be treated as a https://datatracker.ietf.org/doc/html/rfc6749#section-2.1[public client^].
+Public clients should be required to use https://datatracker.ietf.org/doc/html/rfc7636#section-4[Proof Key for Code Exchange] (PKCE).
+
+Continuing the <<enable-cors-configuration,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:
+
+[[configure-public-client-example]]
+.Yaml
+[source,yaml,role="primary"]
+----
+include::{examples-dir}/src/main/java/sample/pkce/application.yml[]
+----
+
+.Java
+[source,java,role="secondary"]
+----
+include::{examples-dir}/src/main/java/sample/pkce/ClientConfig.java[tag=client,indent=0]
+----
+
+NOTE: The `requireProofKey` setting is helpful in situations where you forget to include the `code_challenge` and `code_challenge_method` query parameters because you will receive an error indicating PKCE is required during the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-authorization-endpoint[Authorization Request] instead of a general client authentication error during the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[Token Request].
+
+[[authenticate-with-client]]
+== 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.
+
+NOTE: 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 xref:{docs-dir}/protocol-endpoints.adoc#oauth2-authorization-endpoint[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 xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[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.
+
+TIP: It is recommended that you use a robust client-side library supported by your single-page app framework to handle the Authorization Code flow.

+ 5 - 5
docs/src/docs/asciidoc/guides/how-to-social-login.adoc

@@ -135,7 +135,7 @@ For example, assuming Google is configured as a social login provider with a `re
 .`FederatedIdentityAuthenticationEntryPoint`
 [source,java]
 ----
-include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityAuthenticationEntryPoint.java[]
+include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityAuthenticationEntryPoint.java[tags=imports;class]
 ----
 
 [[advanced-use-cases-capture-users]]
@@ -146,7 +146,7 @@ The following example `AuthenticationSuccessHandler` uses a custom component to
 .`FederatedIdentityAuthenticationSuccessHandler`
 [source,java]
 ----
-include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityAuthenticationSuccessHandler.java[]
+include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityAuthenticationSuccessHandler.java[tags=imports;class]
 ----
 
 Using the `AuthenticationSuccessHandler` above, you can plug in your own `Consumer<OAuth2User>` that can capture users in a database or other data store for concepts like Federated Account Linking or JIT Account Provisioning.
@@ -155,7 +155,7 @@ Here is an example that simply stores users in-memory:
 .`UserRepositoryOAuth2UserHandler`
 [source,java]
 ----
-include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/UserRepositoryOAuth2UserHandler.java[]
+include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/UserRepositoryOAuth2UserHandler.java[tags=imports;class]
 ----
 
 [[advanced-use-cases-map-claims]]
@@ -166,7 +166,7 @@ The following example `OAuth2TokenCustomizer` maps a user's claims from an authe
 .`FederatedIdentityIdTokenCustomizer`
 [source,java]
 ----
-include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityIdTokenCustomizer.java[]
+include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityIdTokenCustomizer.java[tags=imports;class]
 ----
 
 You can configure Spring Authorization Server to use this customizer by publishing it as a `@Bean` as in the following example:
@@ -188,7 +188,7 @@ The following example `SecurityConfigurer` combines configuration for all of the
 .`FederatedIdentityConfigurer`
 [source,java]
 ----
-include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityConfigurer.java[]
+include::{samples-dir}/demo-authorizationserver/src/main/java/sample/federation/FederatedIdentityConfigurer.java[tags=imports;class]
 ----
 
 The configurer can be applied using the Spring Security DSL as in the following example:

+ 1 - 0
docs/src/docs/asciidoc/how-to.adoc

@@ -4,6 +4,7 @@
 [[how-to-overview]]
 == List of Guides
 
+* xref:guides/how-to-pkce.adoc[Authenticate using a Single Page Application with PKCE]
 * xref:guides/how-to-social-login.adoc[Authenticate using Social Login]
 * xref:guides/how-to-userinfo.adoc[Customize the OpenID Connect 1.0 UserInfo response]
 * xref:guides/how-to-jpa.adoc[Implement core services with JPA]