Эх сурвалжийг харах

Add How-to: Implement an Extension Authorization Grant Type

Closes gh-686
Joe Grandja 2 жил өмнө
parent
commit
c7b66e7a74

+ 83 - 0
docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationConverter.java

@@ -0,0 +1,83 @@
+/*
+ * 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.extgrant;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+
+public class CustomCodeGrantAuthenticationConverter implements AuthenticationConverter {
+
+	@Nullable
+	@Override
+	public Authentication convert(HttpServletRequest request) {
+		// grant_type (REQUIRED)
+		String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
+		if (!"urn:ietf:params:oauth:grant-type:custom_code".equals(grantType)) { // <1>
+			return null;
+		}
+
+		Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
+
+		MultiValueMap<String, String> parameters = getParameters(request);
+
+		// code (REQUIRED)
+		String code = parameters.getFirst(OAuth2ParameterNames.CODE); // <2>
+		if (!StringUtils.hasText(code) ||
+				parameters.get(OAuth2ParameterNames.CODE).size() != 1) {
+			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
+		}
+
+		Map<String, Object> additionalParameters = new HashMap<>();
+		parameters.forEach((key, value) -> {
+			if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) &&
+					!key.equals(OAuth2ParameterNames.CLIENT_ID) &&
+					!key.equals(OAuth2ParameterNames.CODE)) {
+				additionalParameters.put(key, value.get(0));
+			}
+		});
+
+		return new CustomCodeGrantAuthenticationToken(code, clientPrincipal, additionalParameters); // <3>
+	}
+
+	// @fold:on
+	private static MultiValueMap<String, String> getParameters(HttpServletRequest request) {
+		Map<String, String[]> parameterMap = request.getParameterMap();
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(parameterMap.size());
+		parameterMap.forEach((key, values) -> {
+			if (values.length > 0) {
+				for (String value : values) {
+					parameters.add(key, value);
+				}
+			}
+		});
+		return parameters;
+	}
+	// @fold:off
+
+}

+ 129 - 0
docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationProvider.java

@@ -0,0 +1,129 @@
+/*
+ * 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.extgrant;
+
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.oauth2.core.ClaimAccessor;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
+import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+import org.springframework.util.Assert;
+
+public class CustomCodeGrantAuthenticationProvider implements AuthenticationProvider {
+	// @fold:on
+	private final OAuth2AuthorizationService authorizationService;
+	private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
+
+	public CustomCodeGrantAuthenticationProvider(OAuth2AuthorizationService authorizationService,
+			OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator) {
+		Assert.notNull(authorizationService, "authorizationService cannot be null");
+		Assert.notNull(tokenGenerator, "tokenGenerator cannot be null");
+		this.authorizationService = authorizationService;
+		this.tokenGenerator = tokenGenerator;
+	}
+	// @fold:off
+
+	@Override
+	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+		CustomCodeGrantAuthenticationToken customCodeGrantAuthentication =
+				(CustomCodeGrantAuthenticationToken) authentication;
+
+		// Ensure the client is authenticated
+		OAuth2ClientAuthenticationToken clientPrincipal =
+				getAuthenticatedClientElseThrowInvalidClient(customCodeGrantAuthentication);
+		RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
+
+		// Ensure the client is configured to use this authorization grant type
+		if (!registeredClient.getAuthorizationGrantTypes().contains(customCodeGrantAuthentication.getGrantType())) {
+			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
+		}
+
+		// TODO Validate the code parameter
+
+		// Generate the access token
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.registeredClient(registeredClient)
+				.principal(clientPrincipal)
+				.authorizationServerContext(AuthorizationServerContextHolder.getContext())
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.authorizationGrantType(customCodeGrantAuthentication.getGrantType())
+				.authorizationGrant(customCodeGrantAuthentication)
+				.build();
+
+		OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);
+		if (generatedAccessToken == null) {
+			OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
+					"The token generator failed to generate the access token.", null);
+			throw new OAuth2AuthenticationException(error);
+		}
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
+				generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(),
+				generatedAccessToken.getExpiresAt(), null);
+
+		// Initialize the OAuth2Authorization
+		OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient)
+				.principalName(clientPrincipal.getName())
+				.authorizationGrantType(customCodeGrantAuthentication.getGrantType());
+		if (generatedAccessToken instanceof ClaimAccessor) {
+			authorizationBuilder.token(accessToken, (metadata) ->
+				metadata.put(
+					OAuth2Authorization.Token.CLAIMS_METADATA_NAME,
+					((ClaimAccessor) generatedAccessToken).getClaims())
+			);
+		} else {
+			authorizationBuilder.accessToken(accessToken);
+		}
+		OAuth2Authorization authorization = authorizationBuilder.build();
+
+		// Save the OAuth2Authorization
+		this.authorizationService.save(authorization);
+
+		return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken);
+	}
+
+	@Override
+	public boolean supports(Class<?> authentication) {
+		return CustomCodeGrantAuthenticationToken.class.isAssignableFrom(authentication);
+	}
+
+	// @fold:on
+	private static OAuth2ClientAuthenticationToken getAuthenticatedClientElseThrowInvalidClient(Authentication authentication) {
+		OAuth2ClientAuthenticationToken clientPrincipal = null;
+		if (OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication.getPrincipal().getClass())) {
+			clientPrincipal = (OAuth2ClientAuthenticationToken) authentication.getPrincipal();
+		}
+		if (clientPrincipal != null && clientPrincipal.isAuthenticated()) {
+			return clientPrincipal;
+		}
+		throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+	// @fold:off
+
+}

+ 41 - 0
docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationToken.java

@@ -0,0 +1,41 @@
+/*
+ * 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.extgrant;
+
+import java.util.Map;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken;
+import org.springframework.util.Assert;
+
+public class CustomCodeGrantAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {
+	private final String code;
+
+	public CustomCodeGrantAuthenticationToken(String code, Authentication clientPrincipal,
+			@Nullable Map<String, Object> additionalParameters) {
+		super(new AuthorizationGrantType("urn:ietf:params:oauth:grant-type:custom_code"),
+				clientPrincipal, additionalParameters);
+		Assert.hasText(code, "code cannot be empty");
+		this.code = code;
+	}
+
+	public String getCode() {
+		return this.code;
+	}
+
+}

+ 116 - 0
docs/src/docs/asciidoc/examples/src/main/java/sample/extgrant/SecurityConfig.java

@@ -0,0 +1,116 @@
+/*
+ * 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.extgrant;
+
+import java.util.UUID;
+
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+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.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
+import org.springframework.security.oauth2.server.authorization.token.DelegatingOAuth2TokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.JwtGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2AccessTokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2RefreshTokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+	// @formatter:off
+	@Bean
+	SecurityFilterChain authorizationServerSecurityFilterChain(
+			HttpSecurity http,
+			OAuth2AuthorizationService authorizationService,
+			OAuth2TokenGenerator<?> tokenGenerator) throws Exception {
+
+		OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+				new OAuth2AuthorizationServerConfigurer();
+
+		authorizationServerConfigurer
+			.tokenEndpoint(tokenEndpoint ->
+				tokenEndpoint
+					.accessTokenRequestConverter( // <1>
+						new CustomCodeGrantAuthenticationConverter())
+					.authenticationProvider( // <2>
+						new CustomCodeGrantAuthenticationProvider(
+							authorizationService, tokenGenerator)));
+
+		// @fold:on
+		RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
+
+		http
+			.securityMatcher(endpointsMatcher)
+			.authorizeHttpRequests(authorize ->
+				authorize
+					.anyRequest().authenticated()
+			)
+			.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
+			.apply(authorizationServerConfigurer);
+		// @fold:off
+
+		return http.build();
+	}
+	// @formatter:on
+
+	// @fold:on
+	// @formatter:off
+	@Bean
+	RegisteredClientRepository registeredClientRepository() {
+		RegisteredClient messagingClient = RegisteredClient.withId(UUID.randomUUID().toString())
+				.clientId("messaging-client")
+				.clientSecret("{noop}secret")
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+				.authorizationGrantType(new AuthorizationGrantType("urn:ietf:params:oauth:grant-type:custom_code"))
+				.scope("message.read")
+				.scope("message.write")
+				.build();
+
+		return new InMemoryRegisteredClientRepository(messagingClient);
+	}
+	// @formatter:on
+
+	@Bean
+	OAuth2AuthorizationService authorizationService() {
+		return new InMemoryOAuth2AuthorizationService();
+	}
+
+	@Bean
+	OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) {
+		JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource));
+		OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
+		OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
+		return new DelegatingOAuth2TokenGenerator(
+				jwtGenerator, accessTokenGenerator, refreshTokenGenerator);
+	}
+	// @fold:off
+
+}

+ 74 - 0
docs/src/docs/asciidoc/examples/src/test/java/sample/extgrant/CustomCodeGrantTests.java

@@ -0,0 +1,74 @@
+/*
+ * 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.extgrant;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+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.http.HttpHeaders;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+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.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@ExtendWith(SpringTestContextExtension.class)
+public class CustomCodeGrantTests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Test
+	public void requestWhenTokenRequestValidThenTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfig.class).autowire();
+
+		RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("messaging-client");
+
+		HttpHeaders headers = new HttpHeaders();
+		headers.setBasicAuth(registeredClient.getClientId(),
+				registeredClient.getClientSecret().replace("{noop}", ""));
+
+		// @formatter:off
+		this.mvc.perform(post("/oauth2/token")
+				.param(OAuth2ParameterNames.GRANT_TYPE, "urn:ietf:params:oauth:grant-type:custom_code")
+				.param(OAuth2ParameterNames.CODE, "7QR49T1W3")
+				.headers(headers))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.access_token").isNotEmpty());
+		// @formatter:on
+	}
+
+	@EnableWebSecurity
+	@EnableAutoConfiguration
+	@ComponentScan
+	static class AuthorizationServerConfig {
+	}
+
+}

+ 76 - 0
docs/src/docs/asciidoc/guides/how-to-ext-grant-type.adoc

@@ -0,0 +1,76 @@
+[[how-to-extension-grant-type]]
+= How-to: Implement an Extension Authorization Grant Type
+:index-link: ../how-to.html
+:docs-dir: ..
+:examples-dir: {docs-dir}/examples
+
+This guide shows how to extend xref:{docs-dir}/index.adoc#top[Spring Authorization Server] with an https://datatracker.ietf.org/doc/html/rfc6749#section-4.5[extension authorization grant type].
+The purpose of this guide is to demonstrate how to implement an extension authorization grant type and configure it at the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token endpoint].
+
+Extending Spring Authorization Server with a new authorization grant type requires implementing an `AuthenticationConverter` and `AuthenticationProvider`, and configuring both components at the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token endpoint].
+In addition to the component implementations, a unique absolute URI needs to be assigned for use with the `grant_type` parameter.
+
+* <<implement-authentication-converter>>
+* <<implement-authentication-provider>>
+* <<configure-token-endpoint>>
+* <<request-access-token>>
+
+[[implement-authentication-converter]]
+== Implement AuthenticationConverter
+
+Assuming the absolute URI for the `grant_type` parameter is `urn:ietf:params:oauth:grant-type:custom_code` and the `code` parameter represents the authorization grant, the following example shows a sample implementation of the `AuthenticationConverter`:
+
+.AuthenticationConverter
+[source,java]
+----
+include::{examples-dir}/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationConverter.java[]
+----
+
+TIP: Click on the "Expand folded text" icon in the code sample above to display the full example.
+
+<1> If the `grant_type` parameter is *not* `urn:ietf:params:oauth:grant-type:custom_code`, then return `null`, allowing another `AuthenticationConverter` to process the token request.
+<2> The `code` parameter contains the authorization grant.
+<3> Return an instance of `CustomCodeGrantAuthenticationToken`, which is processed by <<implement-authentication-provider,`CustomCodeGrantAuthenticationProvider`>>.
+
+[[implement-authentication-provider]]
+== Implement AuthenticationProvider
+
+The `AuthenticationProvider` implementation is responsible for validating the authorization grant, and if valid and authorized, issues an access token.
+
+The following example shows a sample implementation of the `AuthenticationProvider`:
+
+.AuthenticationProvider
+[source,java]
+----
+include::{examples-dir}/src/main/java/sample/extgrant/CustomCodeGrantAuthenticationProvider.java[]
+----
+
+NOTE: `CustomCodeGrantAuthenticationProvider` processes `CustomCodeGrantAuthenticationToken`, which is created by <<implement-authentication-converter,`CustomCodeGrantAuthenticationConverter`>>.
+
+[[configure-token-endpoint]]
+== Configure OAuth2 Token Endpoint
+
+The following example shows how to configure the xref:{docs-dir}/protocol-endpoints.adoc#oauth2-token-endpoint[OAuth2 Token endpoint] with the `AuthenticationConverter` and `AuthenticationProvider`:
+
+.SecurityConfig
+[source,java]
+----
+include::{examples-dir}/src/main/java/sample/extgrant/SecurityConfig.java[]
+----
+
+<1> Add the `AuthenticationConverter` to the OAuth2 Token endpoint configuration.
+<2> Add the `AuthenticationProvider` to the OAuth2 Token endpoint configuration.
+
+[[request-access-token]]
+== Request the Access Token
+
+The client can request the access token by making the following (authenticated) request to the OAuth2 Token endpoint:
+
+[source,shell]
+----
+POST /oauth2/token HTTP/1.1
+Authorization: Basic bWVzc2FnaW5nLWNsaWVudDpzZWNyZXQ=
+Content-Type: application/x-www-form-urlencoded
+
+grant_type=urn:ietf:params:oauth:grant-type:custom_code&code=7QR49T1W3
+----

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

@@ -6,5 +6,6 @@
 
 * 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-ext-grant-type.adoc[Implement an Extension Authorization Grant Type]
 * 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]