浏览代码

Remember user consent and make consent page configurable

Closes gh-283
Daniel Garnier-Moiroux 4 年之前
父节点
当前提交
683dad1443
共有 18 个文件被更改,包括 1929 次插入36 次删除
  1. 70 1
      oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java
  2. 105 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentService.java
  3. 199 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java
  4. 56 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentService.java
  5. 146 14
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java
  6. 165 7
      oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java
  7. 152 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentServiceTest.java
  8. 117 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentTest.java
  9. 347 14
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java
  10. 8 0
      samples/boot/oauth2-integration/authorizationserver-custom-consent-page/spring-security-samples-boot-oauth2-integrated-authorizationserver-custom-consent-page.gradle
  11. 32 0
      samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/OAuth2AuthorizationServerCustomConsentPageApplication.java
  12. 109 0
      samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/AuthorizationServerConfig.java
  13. 60 0
      samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/DefaultSecurityConfig.java
  14. 73 0
      samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/Jwks.java
  15. 84 0
      samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/KeyGeneratorUtils.java
  16. 109 0
      samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/web/ConsentController.java
  17. 10 0
      samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/application.yml
  18. 87 0
      samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/templates/consent.html

+ 70 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java

@@ -36,9 +36,11 @@ import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.security.oauth2.jwt.JwtEncoder;
 import org.springframework.security.oauth2.jwt.NimbusJwsEncoder;
 import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService;
 import org.springframework.security.oauth2.server.authorization.JwtEncodingContext;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenCustomizer;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider;
@@ -107,6 +109,7 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 			this.oidcProviderConfigurationEndpointMatcher.matches(request) ||
 			this.authorizationServerMetadataEndpointMatcher.matches(request) ||
 			this.oidcClientRegistrationEndpointMatcher.matches(request);
+	private String consentPage;
 
 	/**
 	 * Sets the repository of registered clients.
@@ -132,6 +135,18 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 		return this;
 	}
 
+	/**
+	 * Sets the authorization consent service.
+	 *
+	 * @param authorizationConsentService the authorization service
+	 * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration
+	 */
+	public OAuth2AuthorizationServerConfigurer<B> authorizationConsentService(OAuth2AuthorizationConsentService authorizationConsentService) {
+		Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null");
+		this.getBuilder().setSharedObject(OAuth2AuthorizationConsentService.class, authorizationConsentService);
+		return this;
+	}
+
 	/**
 	 * Sets the provider settings.
 	 *
@@ -144,6 +159,43 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 		return this;
 	}
 
+	/**
+	 * Specify the URL to redirect Resource Owners to if consent is required during
+	 * the {@code authorization_code} flow. A default consent page will be generated when
+	 * this attribute is not specified.
+	 *
+	 * If a URL is specified, users are required to process the specified URL to generate
+	 * a consent page. The query string will contain the following parameters:
+	 *
+	 * <ul>
+	 * <li>{@code client_id} the client identifier</li>
+	 * <li>{@code scope} the space separated list of scopes present in the authorization request</li>
+	 * <li>{@code state} a CSRF protection token</li>
+	 * </ul>
+	 *
+	 * In general, the consent page should create a form that submits
+	 * a request with the following requirements:
+	 *
+	 * <ul>
+	 * <li>It must be an HTTP POST</li>
+	 * <li>It must be submitted to {@link ProviderSettings#authorizationEndpoint()}</li>
+	 * <li>It must include the received {@code client_id} as an HTTP parameter</li>
+	 * <li>It must include the received {@code state} as an HTTP parameter</li>
+	 * <li>It must include the list of {@code scope}s the {@code Resource Owners}
+	 * consents to as an HTTP parameter</li>
+	 * <li>It must include the {@code consent_action} parameter, with value either
+	 * {@code approve} or {@code cancel} as an HTTP parameter</li>
+	 * </ul>
+	 *
+	 *
+	 * @param consentPage the consent page to redirect to if consent is required (e.g. "/consent")
+	 * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration
+	 */
+	public OAuth2AuthorizationServerConfigurer<B> consentPage(String consentPage) {
+		this.consentPage = consentPage;
+		return this;
+	}
+
 	/**
 	 * Returns a {@link RequestMatcher} for the authorization server endpoints.
 	 *
@@ -263,7 +315,12 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 				new OAuth2AuthorizationEndpointFilter(
 						getRegisteredClientRepository(builder),
 						getAuthorizationService(builder),
-						providerSettings.authorizationEndpoint());
+						getAuthorizationConsentService(builder),
+						providerSettings.authorizationEndpoint()
+				);
+		if (this.consentPage != null) {
+			authorizationEndpointFilter.setUserConsentUri(this.consentPage);
+		}
 		builder.addFilterBefore(postProcess(authorizationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class);
 
 		OAuth2TokenEndpointFilter tokenEndpointFilter =
@@ -347,6 +404,18 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 		return authorizationService;
 	}
 
+	private static <B extends HttpSecurityBuilder<B>> OAuth2AuthorizationConsentService getAuthorizationConsentService(B builder) {
+		OAuth2AuthorizationConsentService authorizationConsentService = builder.getSharedObject(OAuth2AuthorizationConsentService.class);
+		if (authorizationConsentService == null) {
+			authorizationConsentService = getOptionalBean(builder, OAuth2AuthorizationConsentService.class);
+			if (authorizationConsentService == null) {
+				authorizationConsentService = new InMemoryOAuth2AuthorizationConsentService();
+			}
+			builder.setSharedObject(OAuth2AuthorizationConsentService.class, authorizationConsentService);
+		}
+		return authorizationConsentService;
+	}
+
 	private static <B extends HttpSecurityBuilder<B>> JwtEncoder getJwtEncoder(B builder) {
 		JwtEncoder jwtEncoder = builder.getSharedObject(JwtEncoder.class);
 		if (jwtEncoder == null) {

+ 105 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentService.java

@@ -0,0 +1,105 @@
+/*
+ * Copyright 2020-2021 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 org.springframework.security.oauth2.server.authorization;
+
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * An {@link OAuth2AuthorizationConsentService} that stores {@link OAuth2AuthorizationConsent}'s in-memory.
+ *
+ * <p>
+ * <b>NOTE:</b> This implementation should ONLY be used during development/testing.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.2
+ * @see OAuth2AuthorizationConsentService
+ */
+public final class InMemoryOAuth2AuthorizationConsentService implements OAuth2AuthorizationConsentService {
+	private final Map<Integer, OAuth2AuthorizationConsent> authorizationConsents = new ConcurrentHashMap<>();
+
+	/**
+	 * Constructs an {@code InMemoryOAuth2AuthorizationConsentService}.
+	 */
+	public InMemoryOAuth2AuthorizationConsentService() {
+		this(Collections.emptyList());
+	}
+
+	/**
+	 * Constructs an {@code InMemoryOAuth2AuthorizationConsentService} using the provided parameters.
+	 *
+	 * @param authorizationConsents the authorization consent(s)
+	 */
+	public InMemoryOAuth2AuthorizationConsentService(OAuth2AuthorizationConsent... authorizationConsents) {
+		this(Arrays.asList(authorizationConsents));
+	}
+
+	/**
+	 * Constructs an {@code InMemoryOAuth2AuthorizationConsentService} using the provided parameters.
+	 *
+	 * @param authorizationConsents the authorization consent(s)
+	 */
+	public InMemoryOAuth2AuthorizationConsentService(List<OAuth2AuthorizationConsent> authorizationConsents) {
+		Assert.notNull(authorizationConsents, "authorizationConsents cannot be null");
+		authorizationConsents.forEach(authorizationConsent -> {
+			Assert.notNull(authorizationConsent, "authorizationConsent cannot be null");
+			int id = getId(authorizationConsent);
+			Assert.isTrue(!this.authorizationConsents.containsKey(id),
+					"The authorizationConsent must be unique. Found duplicate, with registered client id: ["
+							+ authorizationConsent.getRegisteredClientId()
+							+ "] and principal name: [" + authorizationConsent.getPrincipalName() + "]");
+			this.authorizationConsents.put(id, authorizationConsent);
+		});
+	}
+
+	@Override
+	public void save(OAuth2AuthorizationConsent authorizationConsent) {
+		Assert.notNull(authorizationConsent, "authorizationConsent cannot be null");
+		int id = getId(authorizationConsent);
+		this.authorizationConsents.put(id, authorizationConsent);
+	}
+
+	@Override
+	public void remove(OAuth2AuthorizationConsent authorizationConsent) {
+		Assert.notNull(authorizationConsent, "authorizationConsent cannot be null");
+		int id = getId(authorizationConsent);
+		this.authorizationConsents.remove(id, authorizationConsent);
+	}
+
+	@Override
+	@Nullable
+	public OAuth2AuthorizationConsent findById(String registeredClientId, String principalName) {
+		Assert.hasText(registeredClientId, "registeredClientId cannot be empty");
+		Assert.hasText(principalName, "principalName cannot be empty");
+		int id = getId(registeredClientId, principalName);
+		return this.authorizationConsents.get(id);
+	}
+
+	private static int getId(String registeredClientId, String principalName) {
+		return Objects.hash(registeredClientId, principalName);
+	}
+
+	private static int getId(OAuth2AuthorizationConsent authorizationConsent) {
+		return getId(authorizationConsent.getRegisteredClientId(), authorizationConsent.getPrincipalName());
+	}
+}

+ 199 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java

@@ -0,0 +1,199 @@
+/*
+ * Copyright 2020-2021 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 org.springframework.security.oauth2.server.authorization;
+
+import org.springframework.lang.NonNull;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.core.Version;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+/**
+ * A representation of an OAuth 2.0 "consent" to an Authorization request, which holds state related to the
+ * set of {@link #getAuthorities()} authorities} granted to a {@link #getRegisteredClientId() client} by the
+ * {@link #getPrincipalName() resource owner}.
+ * <p>
+ * When authorizing access for a given client, the resource owner may only grant a subset of the authorities
+ * the client requested. The typical use-case is the {@code authorization_code} flow, in which the client
+ * requests a set of {@code scope}s. The resource owner then selects which scopes they grant to the client.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.2
+ */
+public final class OAuth2AuthorizationConsent implements Serializable {
+	private static final long serialVersionUID = Version.SERIAL_VERSION_UID;
+	private static final String AUTHORITIES_SCOPE_PREFIX = "SCOPE_";
+
+	private final String registeredClientId;
+	private final String principalName;
+	private final Set<GrantedAuthority> authorities;
+
+	private OAuth2AuthorizationConsent(String registeredClientId, String principalName, Set<GrantedAuthority> authorities) {
+		this.registeredClientId = registeredClientId;
+		this.principalName = principalName;
+		this.authorities = Collections.unmodifiableSet(authorities);
+	}
+
+	/**
+	 * Returns the identifier for the {@link RegisteredClient#getId() registered client}.
+	 *
+	 * @return the {@link RegisteredClient#getId()}
+	 */
+	public String getRegisteredClientId() {
+		return this.registeredClientId;
+	}
+
+	/**
+	 * Returns the {@code Principal} name of the resource owner (or client).
+	 *
+	 * @return the {@code Principal} name of the resource owner (or client)
+	 */
+	public String getPrincipalName() {
+		return this.principalName;
+	}
+
+	/**
+	 * Returns the {@link GrantedAuthority authorities} granted to the client by the principal.
+	 *
+	 * @return the {@link GrantedAuthority authorities} granted to the client by the principal.
+	 */
+	public Set<GrantedAuthority> getAuthorities() {
+		return this.authorities;
+	}
+
+	/**
+	 * Convenience method for obtaining the {@code scope}s granted to the client by the principal,
+	 * extracted from the {@link #getAuthorities() authorities}.
+	 *
+	 * @return the {@code scope}s granted to the client by the principal.
+	 */
+	public Set<String> getScopes() {
+		return getAuthorities().stream()
+				.map(GrantedAuthority::getAuthority)
+				.filter(authority -> authority.startsWith(AUTHORITIES_SCOPE_PREFIX))
+				.map(scope -> scope.replaceFirst(AUTHORITIES_SCOPE_PREFIX, ""))
+				.collect(Collectors.toSet());
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the values from the provided {@code OAuth2AuthorizationConsent}.
+	 *
+	 * @param authorizationConsent the {@code OAuth2AuthorizationConsent} used for initializing the {@link Builder}
+	 * @return the {@link Builder}
+	 */
+	public static Builder from(OAuth2AuthorizationConsent authorizationConsent) {
+		Assert.notNull(authorizationConsent, "authorizationConsent cannot be null");
+		return new Builder(
+				authorizationConsent.getRegisteredClientId(),
+				authorizationConsent.getPrincipalName(),
+				authorizationConsent.getAuthorities()
+		);
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the given {@link RegisteredClient#getClientId() registeredClientId}
+	 * and {@code Principal} name.
+	 *
+	 * @param registeredClientId the {@link RegisteredClient#getId()}
+	 * @param principalName the  {@code Principal} name
+	 * @return the {@link Builder}
+	 */
+	public static Builder withId(@NonNull String registeredClientId, @NonNull String principalName) {
+		Assert.hasText(registeredClientId, "registeredClientId cannot be empty");
+		Assert.hasText(principalName, "principalName cannot be empty");
+		return new Builder(registeredClientId, principalName);
+	}
+
+
+	/**
+	 * A builder for {@link OAuth2AuthorizationConsent}.
+	 */
+	public final static class Builder implements Serializable {
+		private static final long serialVersionUID = Version.SERIAL_VERSION_UID;
+
+		private final String registeredClientId;
+		private final String principalName;
+		private final Set<GrantedAuthority> authorities = new HashSet<>();
+
+		private Builder(String registeredClientId, String principalName) {
+			this(registeredClientId, principalName, Collections.emptySet());
+		}
+
+		private Builder(String registeredClientId, String principalName, Set<GrantedAuthority> authorities) {
+			this.registeredClientId = registeredClientId;
+			this.principalName = principalName;
+			if (!CollectionUtils.isEmpty(authorities)) {
+				this.authorities.addAll(authorities);
+			}
+		}
+
+		/**
+		 * Adds a scope to the collection of {@code authorities} in the resulting {@link OAuth2AuthorizationConsent},
+		 * wrapping it in a SimpleGrantedAuthority, prefixed by {@code SCOPE_}. For example, a
+		 * {@code message.write} scope would be stored as {@code SCOPE_message.write}.
+		 *
+		 * @param scope the {@code scope}
+		 * @return the {@code Builder} for further configuration
+		 */
+		public Builder scope(String scope) {
+			authority(new SimpleGrantedAuthority(AUTHORITIES_SCOPE_PREFIX + scope));
+			return this;
+		}
+
+		/**
+		 * Adds a {@link GrantedAuthority} to the collection of {@code authorities} in the
+		 * resulting {@link OAuth2AuthorizationConsent}.
+		 *
+		 * @param authority the {@link GrantedAuthority}
+		 * @return the {@code Builder} for further configuration
+		 */
+		public Builder authority(GrantedAuthority authority) {
+			this.authorities.add(authority);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the {@code authorities}, allowing the ability to add, replace or remove.
+		 *
+		 * @param authoritiesConsumer a {@code Consumer} of the {@code authorities}
+		 * @return the {@code Builder} for further configuration
+		 */
+		public Builder authorities(Consumer<Set<GrantedAuthority>> authoritiesConsumer) {
+			authoritiesConsumer.accept(this.authorities);
+			return this;
+		}
+
+		/**
+		 * Validate the authorities and build the {@link OAuth2AuthorizationConsent}.
+		 * There must be at least one {@link GrantedAuthority}.
+		 *
+		 * @return the {@link OAuth2AuthorizationConsent}
+		 */
+		public OAuth2AuthorizationConsent build() {
+			Assert.notEmpty(this.authorities, "authorities cannot be empty");
+			return new OAuth2AuthorizationConsent(this.registeredClientId, this.principalName, this.authorities);
+		}
+	}
+}

+ 56 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentService.java

@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020-2021 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 org.springframework.security.oauth2.server.authorization;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+
+import java.security.Principal;
+
+/**
+ * Implementations of this interface are responsible for the management
+ * of {@link OAuth2AuthorizationConsent OAuth 2.0 Authorization Consent(s)}.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.2
+ * @see OAuth2AuthorizationConsent
+ */
+public interface OAuth2AuthorizationConsentService {
+	/**
+	 * Saves the {@link OAuth2AuthorizationConsent}.
+	 *
+	 * @param authorizationConsent the {@link OAuth2AuthorizationConsent}
+	 */
+	void save(OAuth2AuthorizationConsent authorizationConsent);
+
+	/**
+	 * Removes the {@link OAuth2AuthorizationConsent}.
+	 *
+	 * @param authorizationConsent the {@link OAuth2AuthorizationConsent}
+	 */
+	void remove(OAuth2AuthorizationConsent authorizationConsent);
+
+	/**
+	 * Returns the {@link OAuth2AuthorizationConsent} identified by the provided
+	 * {@code registeredClientId} and {@code principalName}, or {@code null} if not found.
+	 *
+	 * @param registeredClientId the identifier for the {@link RegisteredClient}
+	 * @param principalName the name of the {@link Principal}
+	 * @return the {@link OAuth2AuthorizationConsent} if found, otherwise {@code null}
+	 */
+	@Nullable
+	OAuth2AuthorizationConsent findById(String registeredClientId, String principalName);
+}

+ 146 - 14
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java

@@ -50,8 +50,11 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResp
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
 import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
@@ -81,6 +84,7 @@ import org.springframework.web.util.UriComponentsBuilder;
  * @since 0.0.1
  * @see RegisteredClientRepository
  * @see OAuth2AuthorizationService
+ * @see OAuth2AuthorizationConsentService
  * @see OAuth2Authorization
  * @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-4.1">Section 4.1 Authorization Code Grant</a>
  * @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-4.1.1">Section 4.1.1 Authorization Request</a>
@@ -99,21 +103,27 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 
 	private final RegisteredClientRepository registeredClientRepository;
 	private final OAuth2AuthorizationService authorizationService;
+	private final OAuth2AuthorizationConsentService authorizationConsentService;
 	private final RequestMatcher authorizationRequestMatcher;
 	private final RequestMatcher userConsentMatcher;
 	private final StringKeyGenerator codeGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);
 	private final StringKeyGenerator stateGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder());
 	private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
+	private String userConsentUri;
 
 	/**
 	 * Constructs an {@code OAuth2AuthorizationEndpointFilter} using the provided parameters.
 	 *
 	 * @param registeredClientRepository the repository of registered clients
 	 * @param authorizationService the authorization service
+	 * @deprecated use
+	 * {@link #OAuth2AuthorizationEndpointFilter(RegisteredClientRepository, OAuth2AuthorizationService, OAuth2AuthorizationConsentService)}
+	 * instead.
 	 */
+	@Deprecated
 	public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository,
 			OAuth2AuthorizationService authorizationService) {
-		this(registeredClientRepository, authorizationService, DEFAULT_AUTHORIZATION_ENDPOINT_URI);
+		this(registeredClientRepository, authorizationService, new InMemoryOAuth2AuthorizationConsentService());
 	}
 
 	/**
@@ -122,14 +132,49 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 	 * @param registeredClientRepository the repository of registered clients
 	 * @param authorizationService the authorization service
 	 * @param authorizationEndpointUri the endpoint {@code URI} for authorization requests
+	 * @deprecated use
+	 * {@link #OAuth2AuthorizationEndpointFilter(RegisteredClientRepository, OAuth2AuthorizationService, OAuth2AuthorizationConsentService, String)}
+	 * instead.
 	 */
+	@Deprecated
 	public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository,
 			OAuth2AuthorizationService authorizationService, String authorizationEndpointUri) {
+		this(registeredClientRepository,
+				authorizationService,
+				new InMemoryOAuth2AuthorizationConsentService(),
+				authorizationEndpointUri);
+	}
+
+	/**
+	 * Constructs an {@code OAuth2AuthorizationEndpointFilter} using the provided parameters.
+	 *
+	 * @param registeredClientRepository the repository of registered clients
+	 * @param authorizationService the authorization service
+	 * @param authorizationConsentService the authorization consent service
+	 */
+	public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository,
+			OAuth2AuthorizationService authorizationService, OAuth2AuthorizationConsentService authorizationConsentService) {
+		this(registeredClientRepository, authorizationService, authorizationConsentService, DEFAULT_AUTHORIZATION_ENDPOINT_URI);
+	}
+
+	/**
+	 * Constructs an {@code OAuth2AuthorizationEndpointFilter} using the provided parameters.
+	 *
+	 * @param registeredClientRepository the repository of registered clients
+	 * @param authorizationService the authorization service
+	 * @param consentService the consent service
+	 * @param authorizationEndpointUri the endpoint {@code URI} for authorization requests
+	 */
+	public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository,
+			OAuth2AuthorizationService authorizationService, OAuth2AuthorizationConsentService consentService,
+			String authorizationEndpointUri) {
 		Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
 		Assert.notNull(authorizationService, "authorizationService cannot be null");
+		Assert.notNull(consentService, "consentService cannot be null");
 		Assert.hasText(authorizationEndpointUri, "authorizationEndpointUri cannot be empty");
 		this.registeredClientRepository = registeredClientRepository;
 		this.authorizationService = authorizationService;
+		this.authorizationConsentService = consentService;
 
 		RequestMatcher authorizationRequestGetMatcher = new AntPathRequestMatcher(
 				authorizationEndpointUri, HttpMethod.GET.name());
@@ -150,6 +195,17 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 				authorizationRequestPostMatcher, consentActionMatcher);
 	}
 
+	/**
+	 * Specify the URL to redirect Resource Owners to if consent is required. A default consent
+	 * page will be generated when this attribute is not specified.
+	 *
+	 * @param customConsentUri the URI of the custom consent page to redirect to if consent is required (e.g. "/consent")
+	 * @see org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer#consentPage(String)
+	 */
+	public final void setUserConsentUri(String customConsentUri) {
+		this.userConsentUri = customConsentUri;
+	}
+
 	@Override
 	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
 			throws ServletException, IOException {
@@ -203,7 +259,8 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 				.attribute(Principal.class.getName(), principal)
 				.attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest);
 
-		if (requireUserConsent(registeredClient, authorizationRequest)) {
+		OAuth2AuthorizationConsent previousConsent = this.authorizationConsentService.findById(registeredClient.getClientId(), principal.getName());
+		if (requireUserConsent(registeredClient, authorizationRequest, previousConsent)) {
 			String state = this.stateGenerator.generateKey();
 			OAuth2Authorization authorization = builder
 					.attribute(OAuth2ParameterNames.STATE, state)
@@ -212,7 +269,17 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 
 			// TODO Need to remove 'in-flight' authorization if consent step is not completed (e.g. approved or cancelled)
 
-			UserConsentPage.displayConsent(request, response, registeredClient, authorization);
+			if (this.hasCustomUserConsentPage()) {
+				String redirect = UriComponentsBuilder
+						.fromUriString(this.userConsentUri)
+						.queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", authorizationRequest.getScopes()))
+						.queryParam(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+						.queryParam(OAuth2ParameterNames.STATE, state)
+						.toUriString();
+				this.redirectStrategy.sendRedirect(request, response, redirect);
+			} else {
+				UserConsentPage.displayConsent(request, response, registeredClient, authorization, previousConsent);
+			}
 		} else {
 			Instant issuedAt = Instant.now();
 			Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES);		// TODO Allow configuration for authorization code time-to-live
@@ -237,13 +304,26 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 		}
 	}
 
-	private static boolean requireUserConsent(RegisteredClient registeredClient, OAuth2AuthorizationRequest authorizationRequest) {
+	private boolean hasCustomUserConsentPage() {
+		return this.userConsentUri != null;
+	}
+
+	private boolean requireUserConsent(RegisteredClient registeredClient, OAuth2AuthorizationRequest authorizationRequest, OAuth2AuthorizationConsent previousConsent) {
+		if (!registeredClient.getClientSettings().requireUserConsent()) {
+			return false;
+		}
 		// openid scope does not require consent
 		if (authorizationRequest.getScopes().contains(OidcScopes.OPENID) &&
 				authorizationRequest.getScopes().size() == 1) {
 			return false;
 		}
-		return registeredClient.getClientSettings().requireUserConsent();
+
+		if (previousConsent != null &&
+				previousConsent.getScopes().containsAll(authorizationRequest.getScopes())) {
+			return false;
+		}
+
+		return true;
 	}
 
 	private void processUserConsent(HttpServletRequest request, HttpServletResponse response)
@@ -283,6 +363,18 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 			// openid scope is auto-approved as it does not require consent
 			authorizedScopes.add(OidcScopes.OPENID);
 		}
+
+		OAuth2AuthorizationConsent previousConsent = this.authorizationConsentService.findById(
+				userConsentRequestContext.getClientId(),
+				userConsentRequestContext.getAuthorization().getPrincipalName()
+		);
+		for (String requestedScope : userConsentRequestContext.getAuthorizationRequest().getScopes()) {
+			if (previousConsent != null && previousConsent.getScopes().contains(requestedScope)) {
+				authorizedScopes.add(requestedScope);
+			}
+		}
+		saveAuthorizationConsent(previousConsent, userConsentRequestContext);
+
 		OAuth2Authorization authorization = OAuth2Authorization.from(userConsentRequestContext.getAuthorization())
 				.token(authorizationCode)
 				.attributes(attrs -> {
@@ -296,6 +388,28 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 				authorizationCode, userConsentRequestContext.getAuthorizationRequest().getState());
 	}
 
+	private void saveAuthorizationConsent(OAuth2AuthorizationConsent previousConsent, UserConsentRequestContext userConsentRequestContext) {
+		if (CollectionUtils.isEmpty(userConsentRequestContext.getScopes())) {
+			return;
+		}
+
+		OAuth2AuthorizationConsent.Builder userConsentBuilder;
+		if (previousConsent == null) {
+			userConsentBuilder = OAuth2AuthorizationConsent.withId(
+					userConsentRequestContext.getClientId(),
+					userConsentRequestContext.getAuthorization().getPrincipalName()
+			);
+		} else {
+			userConsentBuilder = OAuth2AuthorizationConsent.from(previousConsent);
+		}
+
+		for (String authorizedScope : userConsentRequestContext.getScopes()) {
+			userConsentBuilder.scope(authorizedScope);
+		}
+		OAuth2AuthorizationConsent userConsent = userConsentBuilder.build();
+		this.authorizationConsentService.save(userConsent);
+	}
+
 	private void validateAuthorizationRequest(OAuth2AuthorizationRequestContext authorizationRequestContext) {
 		// ---------------
 		// Validate the request to ensure all required parameters are present and valid
@@ -600,7 +714,7 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 
 		private static Set<String> extractScopes(MultiValueMap<String, String> parameters) {
 			List<String> scope = parameters.get(OAuth2ParameterNames.SCOPE);
-			return !CollectionUtils.isEmpty(scope) ? new HashSet<>(scope) : Collections.emptySet();
+			return !CollectionUtils.isEmpty(scope) ? new HashSet<>(scope) : new HashSet<>();
 		}
 
 		private OAuth2Authorization getAuthorization() {
@@ -700,9 +814,10 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 		private static final String CONSENT_ACTION_CANCEL = "cancel";
 
 		private static void displayConsent(HttpServletRequest request, HttpServletResponse response,
-				RegisteredClient registeredClient, OAuth2Authorization authorization) throws IOException {
+				RegisteredClient registeredClient, OAuth2Authorization authorization,
+				OAuth2AuthorizationConsent previousConsent) throws IOException {
 
-			String consentPage = generateConsentPage(request, registeredClient, authorization);
+			String consentPage = generateConsentPage(request, registeredClient, authorization, previousConsent);
 			response.setContentType(TEXT_HTML_UTF8.toString());
 			response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length);
 			response.getWriter().write(consentPage);
@@ -717,14 +832,21 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 		}
 
 		private static String generateConsentPage(HttpServletRequest request,
-				RegisteredClient registeredClient, OAuth2Authorization authorization) {
-
+				RegisteredClient registeredClient, OAuth2Authorization authorization, OAuth2AuthorizationConsent previousConsent) {
 			OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(
 					OAuth2AuthorizationRequest.class.getName());
-			Set<String> scopes = new HashSet<>(authorizationRequest.getScopes());
-			scopes.remove(OidcScopes.OPENID);		// openid scope does not require consent
-			String state = authorization.getAttribute(
-					OAuth2ParameterNames.STATE);
+
+			Set<String> scopes = new HashSet<>();
+			Set<String> previouslyApprovedScopes = new HashSet<>();
+			for (String scope : authorizationRequest.getScopes()) {
+				if (previousConsent != null && previousConsent.getScopes().contains(scope)) {
+					previouslyApprovedScopes.add(scope);
+				} else if (!scope.equals(OidcScopes.OPENID)) { // openid scope does not require consent
+					scopes.add(scope);
+				}
+			}
+
+			String state = authorization.getAttribute(OAuth2ParameterNames.STATE);
 
 			StringBuilder builder = new StringBuilder();
 
@@ -764,6 +886,16 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
 				builder.append("                </div>");
 			}
 
+			if (!previouslyApprovedScopes.isEmpty()) {
+				builder.append("                <p>You have already granted the following permissions to the above app:</p>");
+				for (String scope : previouslyApprovedScopes) {
+					builder.append("                <div class=\"form-group form-check py-1\">");
+					builder.append("                    <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
+					builder.append("                    <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
+					builder.append("                </div>");
+				}
+			}
+
 			builder.append("                <div class=\"form-group pt-3\">");
 			builder.append("                    <button class=\"btn btn-primary btn-lg\" type=\"submit\" name=\"consent_action\" value=\"approve\">Submit Consent</button>");
 			builder.append("                </div>");

+ 165 - 7
oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java

@@ -15,9 +15,11 @@
  */
 package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization;
 
+import java.net.URLDecoder;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.security.Principal;
+import java.text.MessageFormat;
 import java.util.Base64;
 import java.util.List;
 import java.util.Set;
@@ -40,6 +42,7 @@ import org.springframework.http.HttpStatus;
 import org.springframework.http.converter.HttpMessageConverter;
 import org.springframework.mock.http.client.MockClientHttpResponse;
 import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
 import org.springframework.security.config.test.SpringTestRule;
@@ -71,11 +74,15 @@ import org.springframework.security.oauth2.server.authorization.client.TestRegis
 import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.MvcResult;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.hamcrest.CoreMatchers.containsString;
@@ -115,6 +122,7 @@ public class OAuth2AuthorizationCodeGrantTests {
 	private static ProviderSettings providerSettings;
 	private static HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter =
 			new OAuth2AccessTokenResponseHttpMessageConverter();
+	private static String consentPage = "/custom-consent";
 
 	@Rule
 	public final SpringTestRule spring = new SpringTestRule();
@@ -237,11 +245,9 @@ public class OAuth2AuthorizationCodeGrantTests {
 
 	private OAuth2AccessTokenResponse assertTokenRequestReturnsAccessTokenResponse(RegisteredClient registeredClient,
 			OAuth2Authorization authorization, String tokenEndpointUri) throws Exception {
-
 		MvcResult mvcResult = this.mvc.perform(post(tokenEndpointUri)
 				.params(getTokenRequestParameters(registeredClient, authorization))
-				.header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth(
-						registeredClient.getClientId(), registeredClient.getClientSecret())))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
 				.andExpect(status().isOk())
 				.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
 				.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
@@ -296,6 +302,8 @@ public class OAuth2AuthorizationCodeGrantTests {
 				.params(getTokenRequestParameters(registeredClient, authorization))
 				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
 				.param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER))
+				.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+				.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
 				.andExpect(status().isOk())
 				.andExpect(jsonPath("$.access_token").isNotEmpty())
 				.andExpect(jsonPath("$.token_type").isNotEmpty())
@@ -326,8 +334,128 @@ public class OAuth2AuthorizationCodeGrantTests {
 
 		this.mvc.perform(post(OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI)
 				.params(getTokenRequestParameters(registeredClient, authorization))
-				.header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth(
-						registeredClient.getClientId(), registeredClient.getClientSecret())));
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)));
+	}
+
+	@Test
+	public void requestWhenRequiresConsentThenDisplaysConsentPage() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add("message.read");
+					scopes.add("message.write");
+				})
+				.clientSettings(settings -> settings.requireUserConsent(true))
+				.build();
+		when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		String consentPage = this.mvc.perform(get(OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.params(getAuthorizationRequestParameters(registeredClient))
+				.with(user("user")))
+				.andExpect(status().is2xxSuccessful())
+				.andReturn()
+				.getResponse()
+				.getContentAsString();
+
+
+		assertThat(consentPage).contains("Consent required");
+		assertThat(consentPage).contains(scopeCheckbox("message.read"));
+		assertThat(consentPage).contains(scopeCheckbox("message.write"));
+	}
+
+	@Test
+	public void requestWhenConsentRequestReturnAccessTokenResponse() throws Exception {
+		final String stateParameter = "consent-state";
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add("message.read");
+					scopes.add("message.write");
+				})
+				.clientSettings(settings -> settings.requireUserConsent(true))
+				.build();
+		when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+		OAuth2Authorization stateTokenAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName("user")
+				.build();
+
+		when(authorizationService.findByToken(
+				eq(stateParameter),
+				eq(new OAuth2TokenType(OAuth2ParameterNames.STATE))))
+				.thenReturn(stateTokenAuthorization);
+
+		MvcResult mvcResult = this.mvc.perform(post(OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(OAuth2ParameterNames.SCOPE, "message.read")
+				.param(OAuth2ParameterNames.SCOPE, "message.write")
+				.param(OAuth2ParameterNames.STATE, stateParameter)
+				.param("consent_action", "approve")
+				.with(user("user")))
+				.andExpect(status().is3xxRedirection())
+				.andReturn();
+
+		assertThat(mvcResult.getResponse().getRedirectedUrl()).matches("https://example.com\\?code=.{15,}&state=state");
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization authorizationCodeAuthorization = authorizationCaptor.getValue();
+		when(authorizationService.findByToken(
+				eq(authorizationCodeAuthorization.getToken(OAuth2AuthorizationCode.class).getToken().getTokenValue()),
+				eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+				.thenReturn(authorizationCodeAuthorization);
+
+		this.mvc.perform(post(OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
+				.andExpect(status().isOk())
+				.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+				.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+				.andExpect(jsonPath("$.access_token").isNotEmpty())
+				.andExpect(jsonPath("$.token_type").isNotEmpty())
+				.andExpect(jsonPath("$.expires_in").isNotEmpty())
+				.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+				.andExpect(jsonPath("$.scope").isNotEmpty())
+				.andReturn();
+	}
+
+	@Test
+	public void requestWhenCustomConsentPageConfiguredThenRedirect() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomConsentPage.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add("message.read");
+					scopes.add("message.write");
+				})
+				.clientSettings(settings -> settings.requireUserConsent(true))
+				.build();
+		when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		MvcResult mvcResult = this.mvc.perform(get(OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.params(getAuthorizationRequestParameters(registeredClient))
+				.with(user("user")))
+				.andExpect(status().is3xxRedirection())
+				.andReturn();
+
+		String locationHeader = URLDecoder.decode(mvcResult.getResponse().getRedirectedUrl(), StandardCharsets.UTF_8.name());
+		UriComponents redirectedUrl = UriComponentsBuilder.fromUriString(locationHeader).build();
+		MultiValueMap<String, String> redirectQueryParams = redirectedUrl.getQueryParams();
+
+		assertThat(redirectedUrl.getPath()).isEqualTo(consentPage);
+		assertThat(redirectQueryParams.getFirst(OAuth2ParameterNames.SCOPE)).isEqualTo("message.read message.write");
+		assertThat(redirectQueryParams.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo(registeredClient.getClientId());
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		assertThat(redirectQueryParams.getFirst(OAuth2ParameterNames.STATE)).isEqualTo(authorization.getAttribute(OAuth2ParameterNames.STATE));
 	}
 
 	private static MultiValueMap<String, String> getAuthorizationRequestParameters(RegisteredClient registeredClient) {
@@ -350,12 +478,21 @@ public class OAuth2AuthorizationCodeGrantTests {
 		return parameters;
 	}
 
-	private static String encodeBasicAuth(String clientId, String secret) throws Exception {
+	private static String getAuthorizationHeader(RegisteredClient registeredClient) throws Exception {
+		String clientId = registeredClient.getClientId();
+		String secret = registeredClient.getClientSecret();
 		clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name());
 		secret = URLEncoder.encode(secret, StandardCharsets.UTF_8.name());
 		String credentialsString = clientId + ":" + secret;
 		byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8));
-		return new String(encodedBytes, StandardCharsets.UTF_8);
+		return "Basic " + new String(encodedBytes, StandardCharsets.UTF_8);
+	}
+
+	private static String scopeCheckbox(String scope) {
+		return MessageFormat.format(
+				"<input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"{0}\" id=\"{0}\" checked>",
+				scope
+		);
 	}
 
 	@EnableWebSecurity
@@ -418,4 +555,25 @@ public class OAuth2AuthorizationCodeGrantTests {
 		}
 	}
 
+	@EnableWebSecurity
+	static class AuthorizationServerConfigurationCustomConsentPage extends AuthorizationServerConfiguration {
+		// @formatter:off
+		@Bean
+		public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
+					new OAuth2AuthorizationServerConfigurer<>();
+			authorizationServerConfigurer.consentPage(consentPage);
+			RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
+
+			http
+					.requestMatcher(endpointsMatcher)
+					.authorizeRequests(authorizeRequests ->
+							authorizeRequests.anyRequest().authenticated()
+					)
+					.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
+					.apply(authorizationServerConfigurer);
+			return http.build();
+		}
+		// @formatter:on
+	}
 }

+ 152 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentServiceTest.java

@@ -0,0 +1,152 @@
+/*
+ * Copyright 2020-2021 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 org.springframework.security.oauth2.server.authorization;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link InMemoryOAuth2AuthorizationConsentService}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class InMemoryOAuth2AuthorizationConsentServiceTest {
+	private InMemoryOAuth2AuthorizationConsentService consentService;
+
+	private static final String CLIENT_ID = "client-id";
+	private static final String PRINCIPAL_NAME = "principal-name";
+	private static final OAuth2AuthorizationConsent CONSENT = OAuth2AuthorizationConsent
+			.withId(CLIENT_ID, PRINCIPAL_NAME)
+			.authority(new SimpleGrantedAuthority("some.authority"))
+			.build();
+
+	@Before
+	public void setUp() throws Exception {
+		this.consentService = new InMemoryOAuth2AuthorizationConsentService();
+		this.consentService.save(CONSENT);
+	}
+
+	@Test
+	public void constructorVaragsWhenAuthorizationConsentNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new InMemoryOAuth2AuthorizationConsentService((OAuth2AuthorizationConsent) null))
+				.withMessage("authorizationConsent cannot be null");
+	}
+
+	@Test
+	public void constructorListWhenAuthorizationConsentsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new InMemoryOAuth2AuthorizationConsentService((List<OAuth2AuthorizationConsent>) null))
+				.withMessage("authorizationConsents cannot be null");
+	}
+
+	@Test
+	public void constructorWhenDuplicateAuthorizationConsentsThenThrowIllegalArgumentException() {
+		OAuth2AuthorizationConsent authorizationConsent = OAuth2AuthorizationConsent.withId("client-id", "principal-name")
+				.scope("thing.write") // must have at least one scope
+				.build();
+
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new InMemoryOAuth2AuthorizationConsentService(authorizationConsent, authorizationConsent))
+				.withMessage("The authorizationConsent must be unique. Found duplicate, with registered client id: [client-id] and principal name: [principal-name]");
+	}
+
+	@Test
+	public void saveWhenConsentNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.consentService.save(null))
+				.withMessage("authorizationConsent cannot be null");
+	}
+
+	@Test
+	public void saveWhenConsentNewThenSaved() {
+		OAuth2AuthorizationConsent expectedConsent = OAuth2AuthorizationConsent
+				.withId("new-client", "new-principal")
+				.authority(new SimpleGrantedAuthority("new.authority"))
+				.build();
+
+		this.consentService.save(expectedConsent);
+
+		OAuth2AuthorizationConsent consent =
+				this.consentService.findById("new-client", "new-principal");
+		assertThat(consent).isEqualTo(expectedConsent);
+	}
+
+	@Test
+	public void saveWhenConsentExistsThenUpdated() {
+		OAuth2AuthorizationConsent expectedConsent = OAuth2AuthorizationConsent
+				.from(CONSENT)
+				.authority(new SimpleGrantedAuthority("new.authority"))
+				.build();
+
+		this.consentService.save(expectedConsent);
+
+		OAuth2AuthorizationConsent consent =
+				this.consentService.findById(CLIENT_ID, PRINCIPAL_NAME);
+		assertThat(consent).isEqualTo(expectedConsent);
+		assertThat(consent).isNotEqualTo(CONSENT);
+
+	}
+
+	@Test
+	public void removeNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.consentService.remove(null))
+				.withMessage("authorizationConsent cannot be null");
+	}
+
+	@Test
+	public void removeWhenConsentProvidedThenRemoved() {
+		this.consentService.remove(CONSENT);
+
+		assertThat(this.consentService.findById(CLIENT_ID, PRINCIPAL_NAME))
+				.isNull();
+	}
+
+	@Test
+	public void findWhenRegisteredClientIdNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.consentService.findById(null, "some-user"))
+				.withMessage("registeredClientId cannot be empty");
+	}
+
+	@Test
+	public void findWhenPrincipalNameNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.consentService.findById("some-client", null))
+				.withMessage("principalName cannot be empty");
+	}
+
+	@Test
+	public void findWhenConsentExistsThenFound() {
+		assertThat(this.consentService.findById(CLIENT_ID, PRINCIPAL_NAME))
+				.isEqualTo(CONSENT);
+	}
+
+	@Test
+	public void findWhenConsentDoesNotExistThenNull() {
+		this.consentService.save(CONSENT);
+
+		assertThat(this.consentService.findById("unknown-client", PRINCIPAL_NAME)).isNull();
+		assertThat(this.consentService.findById(CLIENT_ID, "unkown-user")).isNull();
+	}
+}

+ 117 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentTest.java

@@ -0,0 +1,117 @@
+/*
+ * Copyright 2020-2021 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 org.springframework.security.oauth2.server.authorization;
+
+import org.junit.Test;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OAuth2AuthorizationConsent}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OAuth2AuthorizationConsentTest {
+	@Test
+	public void fromWhenAuthorizationConsentNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> OAuth2AuthorizationConsent.from(null))
+				.withMessage("authorizationConsent cannot be null");
+	}
+
+	@Test
+	public void withClientIdAndPrincipalWhenClientIdNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> OAuth2AuthorizationConsent.withId(null, "some-user"))
+				.withMessage("registeredClientId cannot be empty");
+	}
+
+	@Test
+	public void withClientIdAndPrincipalWhenPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> OAuth2AuthorizationConsent.withId("some-client", null))
+				.withMessage("principalName cannot be empty");
+	}
+
+	@Test
+	public void buildWhenAuthoritiesEmptyThenThrowIllegalArgumentException() {
+		OAuth2AuthorizationConsent.Builder builder = OAuth2AuthorizationConsent.withId("some-client", "some-user");
+		assertThatIllegalArgumentException()
+				.isThrownBy(builder::build)
+				.withMessage("authorities cannot be empty");
+	}
+
+	@Test
+	public void buildWhenAllAttributesAreProvidedThenAllAttributesAreSet() {
+		OAuth2AuthorizationConsent consent = OAuth2AuthorizationConsent
+				.withId("some-client", "some-user")
+				.scope("resource.read")
+				.scope("resource.write")
+				.authority(new SimpleGrantedAuthority("CLAIM_email"))
+				.build();
+
+		assertThat(consent.getPrincipalName()).isEqualTo("some-user");
+		assertThat(consent.getRegisteredClientId()).isEqualTo("some-client");
+		assertThat(consent.getScopes())
+				.containsExactlyInAnyOrder(
+						"resource.read",
+						"resource.write"
+				);
+		assertThat(consent.getAuthorities())
+				.containsExactlyInAnyOrder(
+						new SimpleGrantedAuthority("SCOPE_resource.read"),
+						new SimpleGrantedAuthority("SCOPE_resource.write"),
+						new SimpleGrantedAuthority("CLAIM_email")
+				);
+	}
+
+	@Test
+	public void fromWhenAuthorizationConsentProvidedThenCopied() {
+		OAuth2AuthorizationConsent previousConsent = OAuth2AuthorizationConsent
+				.withId("some-client", "some-principal")
+				.scope("first.scope")
+				.scope("second.scope")
+				.authority(new SimpleGrantedAuthority("CLAIM_email"))
+				.build();
+
+		OAuth2AuthorizationConsent consent = OAuth2AuthorizationConsent.from(previousConsent).build();
+
+		assertThat(consent.getPrincipalName()).isEqualTo("some-principal");
+		assertThat(consent.getRegisteredClientId()).isEqualTo("some-client");
+		assertThat(consent.getAuthorities())
+				.containsExactlyInAnyOrder(
+						new SimpleGrantedAuthority("SCOPE_first.scope"),
+						new SimpleGrantedAuthority("SCOPE_second.scope"),
+						new SimpleGrantedAuthority("CLAIM_email")
+				);
+	}
+
+	@Test
+	public void authoritiesThenCustomizesAuthorities() {
+		OAuth2AuthorizationConsent consent = OAuth2AuthorizationConsent
+				.withId("some-client", "some-user")
+				.authority(new SimpleGrantedAuthority("some.authority"))
+				.authorities(authorities -> {
+					authorities.clear();
+					authorities.add(new SimpleGrantedAuthority("other.authority"));
+				})
+				.build();
+
+		assertThat(consent.getAuthorities()).containsExactly(new SimpleGrantedAuthority("other.authority"));
+	}
+}

+ 347 - 14
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java

@@ -15,8 +15,13 @@
  */
 package org.springframework.security.oauth2.server.authorization.web;
 
+import java.net.URLDecoder;
 import java.nio.charset.StandardCharsets;
 import java.security.Principal;
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.Set;
 import java.util.function.Consumer;
 
@@ -28,7 +33,7 @@ import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
-
+import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
 import org.springframework.mock.web.MockHttpServletRequest;
@@ -46,20 +51,24 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
 import org.springframework.security.oauth2.core.oidc.OidcScopes;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
 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.client.TestRegisteredClients;
-import org.springframework.security.oauth2.server.authorization.config.ClientSettings;
-import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
 import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
@@ -80,13 +89,15 @@ public class OAuth2AuthorizationEndpointFilterTests {
 	private RegisteredClientRepository registeredClientRepository;
 	private OAuth2AuthorizationService authorizationService;
 	private OAuth2AuthorizationEndpointFilter filter;
+	private OAuth2AuthorizationConsentService consentService;
 	private TestingAuthenticationToken authentication;
 
 	@Before
 	public void setUp() {
 		this.registeredClientRepository = mock(RegisteredClientRepository.class);
 		this.authorizationService = mock(OAuth2AuthorizationService.class);
-		this.filter = new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService);
+		this.consentService = mock(OAuth2AuthorizationConsentService.class);
+		this.filter = new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, this.consentService);
 		this.authentication = new TestingAuthenticationToken("principalName", "password");
 		this.authentication.setAuthenticated(true);
 		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
@@ -101,21 +112,28 @@ public class OAuth2AuthorizationEndpointFilterTests {
 
 	@Test
 	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
-		assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(null, this.authorizationService))
+		assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(null, this.authorizationService, this.consentService))
 				.isInstanceOf(IllegalArgumentException.class)
 				.hasMessage("registeredClientRepository cannot be null");
 	}
 
 	@Test
 	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
-		assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, null))
+		assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, null, this.consentService))
 				.isInstanceOf(IllegalArgumentException.class)
 				.hasMessage("authorizationService cannot be null");
 	}
 
+	@Test
+	public void constructorWhenConsentServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, (OAuth2AuthorizationConsentService) null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("consentService cannot be null");
+	}
+
 	@Test
 	public void constructorWhenAuthorizationEndpointUriNullThenThrowIllegalArgumentException() {
-		assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, null))
+		assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, this.consentService, null))
 				.isInstanceOf(IllegalArgumentException.class)
 				.hasMessage("authorizationEndpointUri cannot be empty");
 	}
@@ -468,7 +486,7 @@ public class OAuth2AuthorizationEndpointFilterTests {
 					scopes.clear();
 					scopes.add(OidcScopes.OPENID);
 				})
-				.clientSettings(ClientSettings::requireUserConsent)
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
 				.build();
 		MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
 		doFilterWhenAuthorizationRequestThenAuthorizationResponse(registeredClient, request);
@@ -570,7 +588,7 @@ public class OAuth2AuthorizationEndpointFilterTests {
 	}
 
 	@Test
-	public void doFilterWhenUserConsentRequiredAndAuthorizationRequestThenUserConsentResponse() throws Exception {
+	public void doFilterWhenUserConsentRequiredAndAuthorizationRequestThenSavesAuthorization() throws Exception {
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
 				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
 				.build();
@@ -583,11 +601,6 @@ public class OAuth2AuthorizationEndpointFilterTests {
 
 		this.filter.doFilter(request, response, filterChain);
 
-		verifyNoInteractions(filterChain);
-
-		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
-		assertThat(response.getContentType().equals(new MediaType("text", "html", StandardCharsets.UTF_8).toString()));
-
 		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
 
 		verify(this.authorizationService).save(authorizationCaptor.capture());
@@ -617,6 +630,150 @@ public class OAuth2AuthorizationEndpointFilterTests {
 		assertThat(authorizationRequest.getAdditionalParameters()).isEmpty();
 	}
 
+	@Test
+	public void doFilterWhenUserConsentRequiredAndAuthorizationRequestThenUserConsentResponse() throws Exception {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add("message.read");
+					scopes.add("message.write");
+				})
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+				.build();
+		when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId()))))
+				.thenReturn(registeredClient);
+
+		MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+		assertThat(response.getContentType().equals(new MediaType("text", "html", StandardCharsets.UTF_8).toString()));
+
+		assertThat(response.getContentAsString()).contains(scopeCheckbox("message.read"));
+		assertThat(response.getContentAsString()).contains(scopeCheckbox("message.write"));
+
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenUserConsentRequiredAndPreviouslyApprovedAndAuthorizationRequestThenUserConsentResponse() throws Exception {
+		String unrelatedPreviouslyApprovedScope = "unrelated.scope";
+		String previouslyApprovedScope = "message.read";
+		String newScope = "message.write";
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add(previouslyApprovedScope);
+					scopes.add(newScope);
+				})
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+				.build();
+		when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId()))))
+				.thenReturn(registeredClient);
+		OAuth2AuthorizationConsent previousConsent = createConsent(
+				registeredClient.getClientId(),
+				this.authentication.getName(),
+				Arrays.asList(previouslyApprovedScope, unrelatedPreviouslyApprovedScope)
+		);
+		when(this.consentService.findById(
+				eq(registeredClient.getClientId()),
+				eq(this.authentication.getName())))
+				.thenReturn(previousConsent);
+
+
+		MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+		assertThat(response.getContentType().equals(new MediaType("text", "html", StandardCharsets.UTF_8).toString()));
+
+		assertThat(response.getContentAsString()).contains(scopeCheckbox(newScope));
+		assertThat(response.getContentAsString()).contains(disabledScopeCheckbox(previouslyApprovedScope));
+		assertThat(response.getContentAsString()).doesNotContain(unrelatedPreviouslyApprovedScope);
+	}
+
+	@Test
+	public void doFilterWhenUserConsentRequiredAndCustomConsentUriAndAuthorizationRequestThenRedirects() throws Exception {
+		this.filter.setUserConsentUri("/consent");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add("message.read");
+					scopes.add("message.write");
+				})
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+				.build();
+		when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId()))))
+				.thenReturn(registeredClient);
+
+		MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
+
+		String consentRedirectHeader = URLDecoder.decode(response.getHeader(HttpHeaders.LOCATION), StandardCharsets.UTF_8.name());
+		UriComponents consentRedirectUri = UriComponentsBuilder.fromUriString(consentRedirectHeader).build();
+		String[] redirectScopes = consentRedirectUri.getQueryParams().getFirst(OAuth2ParameterNames.SCOPE).split(" ");
+		String redirectState = consentRedirectUri.getQueryParams().getFirst(OAuth2ParameterNames.STATE);
+
+		assertThat(consentRedirectUri.getPath()).isEqualTo("/consent");
+		assertThat(consentRedirectUri.getQueryParams().getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo(registeredClient.getClientId());
+		assertThat(redirectScopes).containsExactlyInAnyOrder("message.read", "message.write");
+		assertThat(redirectState).isEqualTo(authorization.getAttribute(OAuth2ParameterNames.STATE));
+	}
+
+	@Test
+	public void doFilterWhenUserConsentRequiredAndAllScopesPreviouslyApprovedAndAuthorizationRequestThenAuthorizationResponse() throws Exception {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add("message.read");
+					scopes.add("message.write");
+				})
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+				.build();
+		when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId()))))
+				.thenReturn(registeredClient);
+		OAuth2AuthorizationConsent previousConsent = createConsent(
+				registeredClient.getClientId(), this.authentication.getName(), Arrays.asList("message.read", "message.write")
+		);
+		when(this.consentService.findById(
+				eq(registeredClient.getClientId()),
+				eq(this.authentication.getName())))
+				.thenReturn(previousConsent);
+
+		MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
+		assertThat(response.getRedirectedUrl()).matches("https://example.com\\?code=.{15,}&state=state");
+	}
+
 	@Test
 	public void doFilterWhenUserConsentRequestMissingStateThenInvalidRequestError() throws Exception {
 		doFilterWhenUserConsentRequestInvalidParameterThenError(
@@ -838,6 +995,154 @@ public class OAuth2AuthorizationEndpointFilterTests {
 				.isEqualTo(registeredClient.getScopes());
 	}
 
+	@Test
+	public void doFilterWhenUserConsentRequestApprovedThenSaveConsent() throws Exception {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add("message.read");
+					scopes.add("message.write");
+				})
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(this.authentication.getName())
+				.attributes(attrs -> attrs.remove(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME))
+				.build();
+		when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId()))))
+				.thenReturn(registeredClient);
+
+		MockHttpServletRequest request = createUserConsentRequest(registeredClient);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		ArgumentCaptor<OAuth2AuthorizationConsent> consentCaptor = ArgumentCaptor.forClass(OAuth2AuthorizationConsent.class);
+
+		verify(this.consentService).save(consentCaptor.capture());
+		OAuth2AuthorizationConsent consent = consentCaptor.getValue();
+		assertThat(consent.getPrincipalName()).isEqualTo(this.authentication.getName());
+		assertThat(consent.getRegisteredClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(consent.getScopes()).containsExactlyInAnyOrder("message.read", "message.write");
+	}
+
+	@Test
+	public void doFilterWhenUserConsentRequestApprovedAndNoScopesThenConsentNotSaved() throws Exception {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(Set::clear)
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(this.authentication.getName())
+				.attributes(attrs -> attrs.remove(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME))
+				.build();
+		when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId()))))
+				.thenReturn(registeredClient);
+
+		MockHttpServletRequest request = createUserConsentRequest(registeredClient);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(this.consentService, never()).save(any());
+	}
+
+	@Test
+	public void doFilterWhenUserConsentRequestApprovedAndPreviousConsentExistsThenUpdatesConsent() throws Exception {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add("message.read");
+					scopes.add("message.write");
+				})
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(this.authentication.getName())
+				.attributes(attrs -> attrs.remove(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME))
+				.build();
+		when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId()))))
+				.thenReturn(registeredClient);
+		OAuth2AuthorizationConsent previousConsent =
+				createConsent(
+						registeredClient.getClientId(),
+						this.authentication.getName(),
+						Collections.singleton("message.read")
+				);
+		when(this.consentService.findById(
+				eq(registeredClient.getClientId()),
+				eq(this.authentication.getName())))
+				.thenReturn(previousConsent);
+
+		MockHttpServletRequest request = createUserConsentRequest(registeredClient);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		ArgumentCaptor<OAuth2AuthorizationConsent> consentCaptor = ArgumentCaptor.forClass(OAuth2AuthorizationConsent.class);
+
+		verify(this.consentService).save(consentCaptor.capture());
+		OAuth2AuthorizationConsent consent = consentCaptor.getValue();
+		assertThat(consent.getPrincipalName()).isEqualTo(this.authentication.getName());
+		assertThat(consent.getRegisteredClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(consent.getScopes()).containsExactlyInAnyOrder("message.read", "message.write");
+	}
+
+	@Test
+	public void doFilterWhenUserConsentRequestApprovedAndPreviousConsentExistsThenSavesOAuth2Authorization() throws Exception {
+		String newScope = "message.write";
+		String previouslyApprovedScope = "message.read";
+		String unrelatedPreviouslyApprovedScope = "unrelated.scope";
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.scopes(scopes -> {
+					scopes.clear();
+					scopes.add(previouslyApprovedScope);
+					scopes.add(newScope);
+				})
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(this.authentication.getName())
+				.attributes(attrs -> attrs.remove(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME))
+				.build();
+		when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId()))))
+				.thenReturn(registeredClient);
+		OAuth2AuthorizationConsent previousConsent =
+				createConsent(
+						registeredClient.getClientId(),
+						this.authentication.getName(),
+						Arrays.asList(previouslyApprovedScope, unrelatedPreviouslyApprovedScope)
+				);
+		when(this.consentService.findById(
+				eq(registeredClient.getClientId()),
+				eq(this.authentication.getName())))
+				.thenReturn(previousConsent);
+
+		MockHttpServletRequest request = createUserConsentRequest(registeredClient);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		Set<String> savedAuthorizationScopes = authorizationCaptor.getValue().getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME);
+		assertThat(savedAuthorizationScopes).containsExactlyInAnyOrder(newScope, previouslyApprovedScope);
+		assertThat(savedAuthorizationScopes).doesNotContain(unrelatedPreviouslyApprovedScope);
+	}
+
 	// gh-243
 	@Test
 	public void doFilterWhenAuthorizationRequestIPv4LoopbackRedirectUriAndDifferentPortThenAuthorizationResponse()
@@ -1008,6 +1313,20 @@ public class OAuth2AuthorizationEndpointFilterTests {
 		request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
 	}
 
+	private static OAuth2AuthorizationConsent createConsent(
+			String registeredClientId,
+			String prinicpalName,
+			Collection<String> scopes
+	) {
+		OAuth2AuthorizationConsent.Builder consentBuilder = OAuth2AuthorizationConsent
+				.withId(registeredClientId, prinicpalName);
+		for (String scope : scopes) {
+			consentBuilder.scope(scope);
+		}
+		return consentBuilder.build();
+
+	}
+
 	private static MockHttpServletRequest createUserConsentRequest(RegisteredClient registeredClient) {
 		String requestUri = OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI;
 		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
@@ -1024,4 +1343,18 @@ public class OAuth2AuthorizationEndpointFilterTests {
 
 		return request;
 	}
+
+	private static String scopeCheckbox(String scope) {
+		return MessageFormat.format(
+				"<input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"{0}\" id=\"{0}\" checked>",
+				scope
+		);
+	}
+
+	private static String disabledScopeCheckbox(String scope) {
+		return MessageFormat.format(
+				"<input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"{0}\" checked disabled>",
+				scope
+		);
+	}
 }

+ 8 - 0
samples/boot/oauth2-integration/authorizationserver-custom-consent-page/spring-security-samples-boot-oauth2-integrated-authorizationserver-custom-consent-page.gradle

@@ -0,0 +1,8 @@
+apply plugin: 'io.spring.convention.spring-sample-boot'
+
+dependencies {
+	compile 'org.springframework.boot:spring-boot-starter-web'
+	compile 'org.springframework.boot:spring-boot-starter-thymeleaf'
+	compile 'org.springframework.boot:spring-boot-starter-security'
+	compile project(':spring-security-oauth2-authorization-server')
+}

+ 32 - 0
samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/OAuth2AuthorizationServerCustomConsentPageApplication.java

@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020 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;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.2
+ */
+@SpringBootApplication
+public class OAuth2AuthorizationServerCustomConsentPageApplication {
+
+	public static void main(String[] args) {
+		SpringApplication.run(OAuth2AuthorizationServerCustomConsentPageApplication.class, args);
+	}
+
+}

+ 109 - 0
samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/AuthorizationServerConfig.java

@@ -0,0 +1,109 @@
+/*
+ * Copyright 2020-2021 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.config;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+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.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
+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.InMemoryOAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+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.ProviderSettings;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import sample.jose.Jwks;
+
+import java.util.UUID;
+
+/**
+ * @author Joe Grandja
+ * @author Daniel Garnier-Moiroux
+ * @since 0.0.1
+ */
+@Configuration(proxyBeanMethods = false)
+public class AuthorizationServerConfig {
+
+	@Bean
+	@Order(Ordered.HIGHEST_PRECEDENCE)
+	public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+		OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
+				new OAuth2AuthorizationServerConfigurer<>();
+		authorizationServerConfigurer.consentPage("/consent");
+		RequestMatcher endpointsMatcher = authorizationServerConfigurer
+				.getEndpointsMatcher();
+
+		http
+			.requestMatcher(endpointsMatcher)
+			.authorizeRequests(authorizeRequests ->
+				authorizeRequests.anyRequest().authenticated()
+			)
+			.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
+			.apply(authorizationServerConfigurer);
+		return http.formLogin(Customizer.withDefaults()).build();
+	}
+
+	// @formatter:off
+	@Bean
+	public RegisteredClientRepository registeredClientRepository() {
+		RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
+				.clientId("messaging-client")
+				.clientSecret("{noop}secret")
+				.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
+				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+				.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+				.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
+				.redirectUri("http://127.0.0.1:8080/authorized")
+				.scope(OidcScopes.OPENID)
+				.scope("message.read")
+				.scope("message.write")
+				.clientSettings(clientSettings -> clientSettings.requireUserConsent(true))
+				.build();
+		return new InMemoryRegisteredClientRepository(registeredClient);
+	}
+	// @formatter:on
+
+	@Bean
+	public JWKSource<SecurityContext> jwkSource() {
+		RSAKey rsaKey = Jwks.generateRsa();
+		JWKSet jwkSet = new JWKSet(rsaKey);
+		return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+	}
+
+	@Bean
+	public ProviderSettings providerSettings() {
+		return new ProviderSettings().issuer("http://auth-server:9000");
+	}
+
+	@Bean
+	public OAuth2AuthorizationConsentService authorizationService() {
+		// Will be used by the ConsentController
+		return new InMemoryOAuth2AuthorizationConsentService();
+	}
+}

+ 60 - 0
samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/config/DefaultSecurityConfig.java

@@ -0,0 +1,60 @@
+/*
+ * Copyright 2020 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.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+
+import static org.springframework.security.config.Customizer.withDefaults;
+
+/**
+ * @author Joe Grandja
+ * @since 0.1.0
+ */
+@EnableWebSecurity
+public class DefaultSecurityConfig {
+
+	// formatter:off
+	@Bean
+	SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
+		http
+			.authorizeRequests(authorizeRequests ->
+				authorizeRequests.anyRequest().authenticated()
+			)
+			.formLogin(withDefaults());
+		return http.build();
+	}
+	// formatter:on
+
+	// @formatter:off
+	@Bean
+	UserDetailsService users() {
+		UserDetails user = User.withDefaultPasswordEncoder()
+				.username("user1")
+				.password("password")
+				.roles("USER")
+				.build();
+		return new InMemoryUserDetailsManager(user);
+	}
+	// @formatter:on
+
+}

+ 73 - 0
samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/Jwks.java

@@ -0,0 +1,73 @@
+/*
+ * Copyright 2020-2021 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.jose;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.OctetSequenceKey;
+import com.nimbusds.jose.jwk.RSAKey;
+
+import javax.crypto.SecretKey;
+import java.security.KeyPair;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.UUID;
+
+/**
+ * @author Joe Grandja
+ * @since 0.1.0
+ */
+public final class Jwks {
+
+	private Jwks() {
+	}
+
+	public static RSAKey generateRsa() {
+		KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
+		RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
+		RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
+		// @formatter:off
+		return new RSAKey.Builder(publicKey)
+				.privateKey(privateKey)
+				.keyID(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+	}
+
+	public static ECKey generateEc() {
+		KeyPair keyPair = KeyGeneratorUtils.generateEcKey();
+		ECPublicKey publicKey = (ECPublicKey) keyPair.getPublic();
+		ECPrivateKey privateKey = (ECPrivateKey) keyPair.getPrivate();
+		Curve curve = Curve.forECParameterSpec(publicKey.getParams());
+		// @formatter:off
+		return new ECKey.Builder(curve, publicKey)
+				.privateKey(privateKey)
+				.keyID(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+	}
+
+	public static OctetSequenceKey generateSecret() {
+		SecretKey secretKey = KeyGeneratorUtils.generateSecretKey();
+		// @formatter:off
+		return new OctetSequenceKey.Builder(secretKey)
+				.keyID(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+	}
+}

+ 84 - 0
samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/jose/KeyGeneratorUtils.java

@@ -0,0 +1,84 @@
+/*
+ * Copyright 2020-2021 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.jose;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import java.math.BigInteger;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.spec.ECFieldFp;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.EllipticCurve;
+
+/**
+ * @author Joe Grandja
+ * @since 0.1.0
+ */
+final class KeyGeneratorUtils {
+
+	private KeyGeneratorUtils() {
+	}
+
+	static SecretKey generateSecretKey() {
+		SecretKey hmacKey;
+		try {
+			hmacKey = KeyGenerator.getInstance("HmacSha256").generateKey();
+		} catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+		return hmacKey;
+	}
+
+	static KeyPair generateRsaKey() {
+		KeyPair keyPair;
+		try {
+			KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
+			keyPairGenerator.initialize(2048);
+			keyPair = keyPairGenerator.generateKeyPair();
+		} catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+		return keyPair;
+	}
+
+	static KeyPair generateEcKey() {
+		EllipticCurve ellipticCurve = new EllipticCurve(
+				new ECFieldFp(
+						new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853951")),
+				new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853948"),
+				new BigInteger("41058363725152142129326129780047268409114441015993725554835256314039467401291"));
+		ECPoint ecPoint = new ECPoint(
+				new BigInteger("48439561293906451759052585252797914202762949526041747995844080717082404635286"),
+				new BigInteger("36134250956749795798585127919587881956611106672985015071877198253568414405109"));
+		ECParameterSpec ecParameterSpec = new ECParameterSpec(
+				ellipticCurve,
+				ecPoint,
+				new BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369"),
+				1);
+
+		KeyPair keyPair;
+		try {
+			KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
+			keyPairGenerator.initialize(ecParameterSpec);
+			keyPair = keyPairGenerator.generateKeyPair();
+		} catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+		return keyPair;
+	}
+}

+ 109 - 0
samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/java/sample/web/ConsentController.java

@@ -0,0 +1,109 @@
+/*
+ * Copyright 2020-2021 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.web;
+
+import java.security.Principal;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * @author Daniel Garnier-Moiroux
+ * @since 0.1.2
+ */
+@Controller
+public class ConsentController {
+
+	private final OAuth2AuthorizationConsentService authorizationConsentService;
+
+	public ConsentController(OAuth2AuthorizationConsentService authorizationConsentService) {
+		this.authorizationConsentService = authorizationConsentService;
+	}
+
+	@GetMapping(value = "/consent")
+	public String consent(
+			Principal principal,
+			Model model,
+			@RequestParam(OAuth2ParameterNames.SCOPE) String scope,
+			@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
+			@RequestParam(OAuth2ParameterNames.STATE) String state
+	) {
+		// Remove scopes that were already approved
+		Set<String> scopesToApprove = new HashSet<>();
+		Set<String> previouslyApprovedScopes = new HashSet<>();
+		OAuth2AuthorizationConsent previousConsent = this.authorizationConsentService.findById(clientId, principal.getName());
+		for (String scopeFromRequest : StringUtils.delimitedListToStringArray(scope, " ")) {
+			if (previousConsent != null && previousConsent.getScopes().contains(scopeFromRequest)) {
+				previouslyApprovedScopes.add(scopeFromRequest);
+			} else {
+				scopesToApprove.add(scopeFromRequest);
+			}
+		}
+
+		model.addAttribute("state", state);
+		model.addAttribute("clientId", clientId);
+		model.addAttribute("scopes", withDescription(scopesToApprove));
+		model.addAttribute("previouslyApprovedScopes", withDescription(previouslyApprovedScopes));
+		model.addAttribute("principalName", principal.getName());
+
+		return "consent";
+	}
+
+	private Set<ScopeWithDescription> withDescription(Set<String> scopes) {
+		return scopes
+				.stream()
+				.map(ScopeWithDescription::new)
+				.collect(Collectors.toSet());
+	}
+
+	private static class ScopeWithDescription {
+		public final String scope;
+		public final String description;
+
+		private final static String DEFAULT_DESCRIPTION = "UNKNOWN SCOPE - We cannot provide information about this permission, use caution when granting this.";
+		private static final Map<String, String> scopeDescriptions = new HashMap<>();
+		static {
+			scopeDescriptions.put(
+					"message.read",
+					"This application will be able to read your message."
+			);
+			scopeDescriptions.put(
+					"message.write",
+					"This application will be able to add new messages. It will also be able to edit and delete existing messages."
+			);
+			scopeDescriptions.put(
+					"other.scope",
+					"This is another scope example of a scope description."
+			);
+		}
+
+		ScopeWithDescription(String scope) {
+			this.scope = scope;
+			this.description = scopeDescriptions.getOrDefault(scope, DEFAULT_DESCRIPTION);
+		}
+	}
+}

+ 10 - 0
samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/application.yml

@@ -0,0 +1,10 @@
+server:
+  port: 9000
+
+logging:
+  level:
+    root: INFO
+    org.springframework.web: INFO
+    org.springframework.security: INFO
+    org.springframework.security.oauth2: INFO
+#    org.springframework.boot.autoconfigure: DEBUG

+ 87 - 0
samples/boot/oauth2-integration/authorizationserver-custom-consent-page/src/main/resources/templates/consent.html

@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
+          integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
+    <title>Consent required</title>
+    <style>
+        body {
+            background-color: aliceblue;
+        }
+    </style>
+</head>
+<body>
+<div class="container">
+    <div class="py-5">
+        <h1 class="text-center text-primary">App permissions</h1>
+    </div>
+    <div class="row">
+        <div class="col text-center">
+            <p>
+                The application
+                <span class="font-weight-bold text-primary" th:text="${clientId}"></span>
+                wants to access your account
+                <span class="font-weight-bold" th:text="${principalName}"></span>
+            </p>
+        </div>
+    </div>
+    <div class="row pb-3">
+        <div class="col text-center"><p>The following permissions are requested by the above app.<br/>Please review
+            these and consent if you approve.</p></div>
+    </div>
+    <div class="row">
+        <div class="col text-center">
+            <form method="post" action="/oauth2/authorize">
+                <input type="hidden" name="client_id" th:value="${clientId}">
+                <input type="hidden" name="state" th:value="${state}">
+
+                <div th:each="scope: ${scopes}" class="form-group form-check py-1">
+                    <input class="form-check-input"
+                           type="checkbox"
+                           name="scope"
+                           th:value="${scope.scope}"
+                           th:id="${scope.scope}"
+                           checked>
+                    <label class="form-check-label font-weight-bold" th:for="${scope.scope}" th:text="${scope.scope}"></label>
+                    <p class="text-primary" th:text="${scope.description}"></p>
+                </div>
+
+                <p th:if="${not #lists.isEmpty(previouslyApprovedScopes)}">You have already granted the following permissions to the above app:</p>
+                <div th:each="scope: ${previouslyApprovedScopes}" class="form-group form-check py-1">
+                    <input class="form-check-input"
+                           type="checkbox"
+                           th:id="${scope.scope}"
+                           disabled
+                           checked>
+                    <label class="form-check-label font-weight-bold" th:for="${scope.scope}" th:text="${scope.scope}"></label>
+                    <p class="text-primary" th:text="${scope.description}"></p>
+                </div>
+
+                <div class="form-group pt-3">
+                    <button class="btn btn-primary btn-lg" type="submit" name="consent_action" value="approve">
+                        Submit Consent
+                    </button>
+                </div>
+                <div class="form-group">
+                    <button class="btn btn-link regular" type="submit" name="consent_action" value="cancel">
+                        Cancel
+                    </button>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="row pt-4">
+        <div class="col text-center">
+            <p>
+                <small>
+                    Your consent to provide access is required.
+                    <br/>If you do not approve, click Cancel, in which case no information will be shared with the app.
+                </small>
+            </p>
+        </div>
+    </div>
+</div>
+</body>
+</html>