diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java
index d2a3cb00..2fd64f2d 100644
--- a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java
+++ b/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 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
+ *
{@code client_id} the client identifier
+ *
{@code scope} the space separated list of scopes present in the authorization request
+ *
{@code state} a CSRF protection token
+ *
+ *
+ * In general, the consent page should create a form that submits
+ * a request with the following requirements:
+ *
+ *
+ *
It must be an HTTP POST
+ *
It must be submitted to {@link ProviderSettings#authorizationEndpoint()}
+ *
It must include the received {@code client_id} as an HTTP parameter
+ *
It must include the received {@code state} as an HTTP parameter
+ *
It must include the list of {@code scope}s the {@code Resource Owners}
+ * consents to as an HTTP parameter
+ *
It must include the {@code consent_action} parameter, with value either
+ * {@code approve} or {@code cancel} as an HTTP parameter
+ *
+ *
+ *
+ * @param consentPage the consent page to redirect to if consent is required (e.g. "/consent")
+ * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration
+ */
+ public OAuth2AuthorizationServerConfigurer 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> 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 > JwtEncoder getJwtEncoder(B builder) {
JwtEncoder jwtEncoder = builder.getSharedObject(JwtEncoder.class);
if (jwtEncoder == null) {
diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentService.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentService.java
new file mode 100644
index 00000000..caef7aa3
--- /dev/null
+++ b/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.
+ *
+ *
+ * NOTE: 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 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 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());
+ }
+}
diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsent.java
new file mode 100644
index 00000000..60ffae27
--- /dev/null
+++ b/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}.
+ *
+ * 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 authorities;
+
+ private OAuth2AuthorizationConsent(String registeredClientId, String principalName, Set 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 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 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 authorities = new HashSet<>();
+
+ private Builder(String registeredClientId, String principalName) {
+ this(registeredClientId, principalName, Collections.emptySet());
+ }
+
+ private Builder(String registeredClientId, String principalName, Set 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> 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);
+ }
+ }
+}
diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentService.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentService.java
new file mode 100644
index 00000000..185a69d1
--- /dev/null
+++ b/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);
+}
diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java
index 2be1d5de..596473c3 100644
--- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java
+++ b/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 Section 4.1 Authorization Code Grant
* @see Section 4.1.1 Authorization Request
@@ -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 extractScopes(MultiValueMap parameters) {
List 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 scopes = new HashSet<>(authorizationRequest.getScopes());
- scopes.remove(OidcScopes.OPENID); // openid scope does not require consent
- String state = authorization.getAttribute(
- OAuth2ParameterNames.STATE);
+
+ Set scopes = new HashSet<>();
+ Set 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(" ");
}
+ if (!previouslyApprovedScopes.isEmpty()) {
+ builder.append("
You have already granted the following permissions to the above app:
");
+ for (String scope : previouslyApprovedScopes) {
+ builder.append("