ソースを参照

Remove OAuth2AuthenticationValidator

Closes gh-891
Joe Grandja 2 年 前
コミット
c326b1a2ba

+ 0 - 40
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthenticationValidator.java

@@ -1,40 +0,0 @@
-/*
- * Copyright 2020-2022 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.authentication;
-
-import org.springframework.security.core.Authentication;
-import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
-
-/**
- * Implementations of this interface are responsible for validating the attribute(s)
- * of the {@link Authentication} associated to the {@link OAuth2AuthenticationContext}.
- *
- * @author Joe Grandja
- * @since 0.2.0
- * @see OAuth2AuthenticationContext
- */
-@FunctionalInterface
-public interface OAuth2AuthenticationValidator {
-
-	/**
-	 * Validate the attribute(s) of the {@link Authentication}.
-	 *
-	 * @param authenticationContext the authentication context
-	 * @throws OAuth2AuthenticationException if the attribute(s) of the {@code Authentication} is invalid
-	 */
-	void validate(OAuth2AuthenticationContext authenticationContext) throws OAuth2AuthenticationException;
-
-}

+ 3 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationContext.java

@@ -18,6 +18,7 @@ package org.springframework.security.oauth2.server.authorization.authentication;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.function.Consumer;
 
 import org.springframework.lang.Nullable;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
@@ -30,7 +31,8 @@ import org.springframework.util.Assert;
  * @author Joe Grandja
  * @since 0.4.0
  * @see OAuth2AuthenticationContext
- * @see OAuth2AuthorizationCodeRequestAuthenticationProvider
+ * @see OAuth2AuthorizationCodeRequestAuthenticationToken
+ * @see OAuth2AuthorizationCodeRequestAuthenticationProvider#setAuthenticationValidator(Consumer)
  */
 public final class OAuth2AuthorizationCodeRequestAuthenticationContext implements OAuth2AuthenticationContext {
 	private final Map<Object, Object> context;

+ 17 - 165
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java

@@ -19,12 +19,9 @@ import java.security.Principal;
 import java.time.Instant;
 import java.util.Base64;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.HashSet;
-import java.util.Map;
 import java.util.Set;
 import java.util.function.Consumer;
-import java.util.function.Function;
 
 import org.springframework.lang.Nullable;
 import org.springframework.security.authentication.AnonymousAuthenticationToken;
@@ -55,8 +52,6 @@ import org.springframework.security.oauth2.server.authorization.token.OAuth2Toke
 import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
-import org.springframework.web.util.UriComponents;
-import org.springframework.web.util.UriComponentsBuilder;
 
 /**
  * An {@link AuthenticationProvider} implementation for the OAuth 2.0 Authorization Request (and Consent)
@@ -66,6 +61,7 @@ import org.springframework.web.util.UriComponentsBuilder;
  * @author Steve Riesenberg
  * @since 0.1.2
  * @see OAuth2AuthorizationCodeRequestAuthenticationToken
+ * @see OAuth2AuthorizationCodeRequestAuthenticationValidator
  * @see OAuth2AuthorizationCodeAuthenticationProvider
  * @see RegisteredClientRepository
  * @see OAuth2AuthorizationService
@@ -78,13 +74,12 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
 	private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
 	private static final StringKeyGenerator DEFAULT_STATE_GENERATOR =
 			new Base64StringKeyGenerator(Base64.getUrlEncoder());
-	private static final Function<String, OAuth2AuthenticationValidator> DEFAULT_AUTHENTICATION_VALIDATOR_RESOLVER =
-			createDefaultAuthenticationValidatorResolver();
 	private final RegisteredClientRepository registeredClientRepository;
 	private final OAuth2AuthorizationService authorizationService;
 	private final OAuth2AuthorizationConsentService authorizationConsentService;
 	private OAuth2TokenGenerator<OAuth2AuthorizationCode> authorizationCodeGenerator = new OAuth2AuthorizationCodeGenerator();
-	private Function<String, OAuth2AuthenticationValidator> authenticationValidatorResolver = DEFAULT_AUTHENTICATION_VALIDATOR_RESOLVER;
+	private Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> authenticationValidator =
+			new OAuth2AuthorizationCodeRequestAuthenticationValidator();
 	private Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer;
 
 	/**
@@ -131,23 +126,20 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
 	}
 
 	/**
-	 * Sets the resolver that resolves an {@link OAuth2AuthenticationValidator} from the provided OAuth 2.0 Authorization Request parameter.
+	 * Sets the {@code Consumer} providing access to the {@link OAuth2AuthorizationCodeRequestAuthenticationContext}
+	 * and is responsible for validating specific OAuth 2.0 Authorization Request parameters
+	 * associated in the {@link OAuth2AuthorizationCodeRequestAuthenticationToken}.
+	 * The default authentication validator is {@link OAuth2AuthorizationCodeRequestAuthenticationValidator}.
 	 *
 	 * <p>
-	 * The following OAuth 2.0 Authorization Request parameters are supported:
-	 * <ol>
-	 * <li>{@link OAuth2ParameterNames#REDIRECT_URI}</li>
-	 * <li>{@link OAuth2ParameterNames#SCOPE}</li>
-	 * </ol>
+	 * <b>NOTE:</b> The authentication validator MUST throw {@link OAuth2AuthorizationCodeRequestAuthenticationException} if validation fails.
 	 *
-	 * <p>
-	 * <b>NOTE:</b> The resolved {@link OAuth2AuthenticationValidator} MUST throw {@link OAuth2AuthorizationCodeRequestAuthenticationException} if validation fails.
-	 *
-	 * @param authenticationValidatorResolver the resolver that resolves an {@link OAuth2AuthenticationValidator} from the provided OAuth 2.0 Authorization Request parameter
+	 * @param authenticationValidator the {@code Consumer} providing access to the {@link OAuth2AuthorizationCodeRequestAuthenticationContext} and is responsible for validating specific OAuth 2.0 Authorization Request parameters
+	 * @since 0.4.0
 	 */
-	public void setAuthenticationValidatorResolver(Function<String, OAuth2AuthenticationValidator> authenticationValidatorResolver) {
-		Assert.notNull(authenticationValidatorResolver, "authenticationValidatorResolver cannot be null");
-		this.authenticationValidatorResolver = authenticationValidatorResolver;
+	public void setAuthenticationValidator(Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> authenticationValidator) {
+		Assert.notNull(authenticationValidator, "authenticationValidator cannot be null");
+		this.authenticationValidator = authenticationValidator;
 	}
 
 	/**
@@ -186,22 +178,17 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
 					authorizationCodeRequestAuthentication, null);
 		}
 
-		OAuth2AuthenticationContext authenticationContext =
+		OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext =
 				OAuth2AuthorizationCodeRequestAuthenticationContext.with(authorizationCodeRequestAuthentication)
 						.registeredClient(registeredClient)
 						.build();
-
-		OAuth2AuthenticationValidator redirectUriValidator = resolveAuthenticationValidator(OAuth2ParameterNames.REDIRECT_URI);
-		redirectUriValidator.validate(authenticationContext);
+		this.authenticationValidator.accept(authenticationContext);
 
 		if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE)) {
 			throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID,
 					authorizationCodeRequestAuthentication, registeredClient);
 		}
 
-		OAuth2AuthenticationValidator scopeValidator = resolveAuthenticationValidator(OAuth2ParameterNames.SCOPE);
-		scopeValidator.validate(authenticationContext);
-
 		// code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE)
 		String codeChallenge = (String) authorizationCodeRequestAuthentication.getAdditionalParameters().get(PkceParameterNames.CODE_CHALLENGE);
 		if (StringUtils.hasText(codeChallenge)) {
@@ -284,13 +271,6 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
 				.build();
 	}
 
-	private OAuth2AuthenticationValidator resolveAuthenticationValidator(String parameterName) {
-		OAuth2AuthenticationValidator authenticationValidator = this.authenticationValidatorResolver.apply(parameterName);
-		return authenticationValidator != null ?
-				authenticationValidator :
-				DEFAULT_AUTHENTICATION_VALIDATOR_RESOLVER.apply(parameterName);
-	}
-
 	private Authentication authenticateAuthorizationConsent(Authentication authentication) throws AuthenticationException {
 		OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication =
 				(OAuth2AuthorizationCodeRequestAuthenticationToken) authentication;
@@ -414,13 +394,6 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
 				.build();
 	}
 
-	private static Function<String, OAuth2AuthenticationValidator> createDefaultAuthenticationValidatorResolver() {
-		Map<String, OAuth2AuthenticationValidator> authenticationValidators = new HashMap<>();
-		authenticationValidators.put(OAuth2ParameterNames.REDIRECT_URI, new DefaultRedirectUriOAuth2AuthenticationValidator());
-		authenticationValidators.put(OAuth2ParameterNames.SCOPE, new DefaultScopeOAuth2AuthenticationValidator());
-		return authenticationValidators::get;
-	}
-
 	private static OAuth2Authorization.Builder authorizationBuilder(RegisteredClient registeredClient, Authentication principal,
 			OAuth2AuthorizationRequest authorizationRequest) {
 		return OAuth2Authorization.withRegisteredClient(registeredClient)
@@ -505,7 +478,6 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
 		boolean redirectOnError = true;
 		if (error.getErrorCode().equals(OAuth2ErrorCodes.INVALID_REQUEST) &&
 				(parameterName.equals(OAuth2ParameterNames.CLIENT_ID) ||
-						parameterName.equals(OAuth2ParameterNames.REDIRECT_URI) ||
 						parameterName.equals(OAuth2ParameterNames.STATE))) {
 			redirectOnError = false;
 		}
@@ -520,14 +492,14 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
 					.redirectUri(redirectUri)
 					.state(state)
 					.build();
-			authorizationCodeRequestAuthenticationResult.setAuthenticated(authorizationCodeRequestAuthentication.isAuthenticated());
 		} else if (!redirectOnError && StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) {
 			authorizationCodeRequestAuthenticationResult = from(authorizationCodeRequestAuthentication)
 					.redirectUri(null)		// Prevent redirects
 					.build();
-			authorizationCodeRequestAuthenticationResult.setAuthenticated(authorizationCodeRequestAuthentication.isAuthenticated());
 		}
 
+		authorizationCodeRequestAuthenticationResult.setAuthenticated(authorizationCodeRequestAuthentication.isAuthenticated());
+
 		throw new OAuth2AuthorizationCodeRequestAuthenticationException(error, authorizationCodeRequestAuthenticationResult);
 	}
 
@@ -569,124 +541,4 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
 
 	}
 
-	private static class DefaultRedirectUriOAuth2AuthenticationValidator implements OAuth2AuthenticationValidator {
-
-		@Override
-		public void validate(OAuth2AuthenticationContext authenticationContext) {
-			OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication =
-					authenticationContext.getAuthentication();
-			RegisteredClient registeredClient = authenticationContext.get(RegisteredClient.class);
-
-			String requestedRedirectUri = authorizationCodeRequestAuthentication.getRedirectUri();
-
-			if (StringUtils.hasText(requestedRedirectUri)) {
-				// ***** redirect_uri is available in authorization request
-
-				UriComponents requestedRedirect = null;
-				try {
-					requestedRedirect = UriComponentsBuilder.fromUriString(requestedRedirectUri).build();
-				} catch (Exception ex) { }
-				if (requestedRedirect == null || requestedRedirect.getFragment() != null) {
-					throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI,
-							authorizationCodeRequestAuthentication, registeredClient);
-				}
-
-				String requestedRedirectHost = requestedRedirect.getHost();
-				if (requestedRedirectHost == null || requestedRedirectHost.equals("localhost")) {
-					// As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-01#section-9.7.1
-					// While redirect URIs using localhost (i.e., "http://localhost:{port}/{path}")
-					// function similarly to loopback IP redirects described in Section 10.3.3,
-					// the use of "localhost" is NOT RECOMMENDED.
-					OAuth2Error error = new OAuth2Error(
-							OAuth2ErrorCodes.INVALID_REQUEST,
-							"localhost is not allowed for the redirect_uri (" + requestedRedirectUri + "). " +
-									"Use the IP literal (127.0.0.1) instead.",
-							"https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-01#section-9.7.1");
-					throwError(error, OAuth2ParameterNames.REDIRECT_URI,
-							authorizationCodeRequestAuthentication, registeredClient, null);
-				}
-
-				if (!isLoopbackAddress(requestedRedirectHost)) {
-					// As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-01#section-9.7
-					// When comparing client redirect URIs against pre-registered URIs,
-					// authorization servers MUST utilize exact string matching.
-					if (!registeredClient.getRedirectUris().contains(requestedRedirectUri)) {
-						throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI,
-								authorizationCodeRequestAuthentication, registeredClient);
-					}
-				} else {
-					// As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-01#section-10.3.3
-					// The authorization server MUST allow any port to be specified at the
-					// time of the request for loopback IP redirect URIs, to accommodate
-					// clients that obtain an available ephemeral port from the operating
-					// system at the time of the request.
-					boolean validRedirectUri = false;
-					for (String registeredRedirectUri : registeredClient.getRedirectUris()) {
-						UriComponentsBuilder registeredRedirect = UriComponentsBuilder.fromUriString(registeredRedirectUri);
-						registeredRedirect.port(requestedRedirect.getPort());
-						if (registeredRedirect.build().toString().equals(requestedRedirect.toString())) {
-							validRedirectUri = true;
-							break;
-						}
-					}
-					if (!validRedirectUri) {
-						throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI,
-								authorizationCodeRequestAuthentication, registeredClient);
-					}
-				}
-
-			} else {
-				// ***** redirect_uri is NOT available in authorization request
-
-				if (authorizationCodeRequestAuthentication.getScopes().contains(OidcScopes.OPENID) ||
-						registeredClient.getRedirectUris().size() != 1) {
-					// redirect_uri is REQUIRED for OpenID Connect
-					throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI,
-							authorizationCodeRequestAuthentication, registeredClient);
-				}
-			}
-		}
-
-		private static boolean isLoopbackAddress(String host) {
-			// IPv6 loopback address should either be "0:0:0:0:0:0:0:1" or "::1"
-			if ("[0:0:0:0:0:0:0:1]".equals(host) || "[::1]".equals(host)) {
-				return true;
-			}
-			// IPv4 loopback address ranges from 127.0.0.1 to 127.255.255.255
-			String[] ipv4Octets = host.split("\\.");
-			if (ipv4Octets.length != 4) {
-				return false;
-			}
-			try {
-				int[] address = new int[ipv4Octets.length];
-				for (int i=0; i < ipv4Octets.length; i++) {
-					address[i] = Integer.parseInt(ipv4Octets[i]);
-				}
-				return address[0] == 127 && address[1] >= 0 && address[1] <= 255 && address[2] >= 0 &&
-						address[2] <= 255 && address[3] >= 1 && address[3] <= 255;
-			} catch (NumberFormatException ex) {
-				return false;
-			}
-		}
-
-	}
-
-	private static class DefaultScopeOAuth2AuthenticationValidator implements OAuth2AuthenticationValidator {
-
-		@Override
-		public void validate(OAuth2AuthenticationContext authenticationContext) {
-			OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication =
-					authenticationContext.getAuthentication();
-			RegisteredClient registeredClient = authenticationContext.get(RegisteredClient.class);
-
-			Set<String> requestedScopes = authorizationCodeRequestAuthentication.getScopes();
-			Set<String> allowedScopes = registeredClient.getScopes();
-			if (!requestedScopes.isEmpty() && !allowedScopes.containsAll(requestedScopes)) {
-				throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE,
-						authorizationCodeRequestAuthentication, registeredClient);
-			}
-		}
-
-	}
-
 }

+ 226 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationValidator.java

@@ -0,0 +1,226 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.util.Set;
+import java.util.function.Consumer;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * A {@code Consumer} providing access to the {@link OAuth2AuthorizationCodeRequestAuthenticationContext}
+ * containing an {@link OAuth2AuthorizationCodeRequestAuthenticationToken}
+ * and is the default {@link OAuth2AuthorizationCodeRequestAuthenticationProvider#setAuthenticationValidator(Consumer) authentication validator}
+ * used for validating specific OAuth 2.0 Authorization Request parameters used in the Authorization Code Grant.
+ *
+ * <p>
+ * The default implementation first validates {@link OAuth2AuthorizationCodeRequestAuthenticationToken#getRedirectUri()}
+ * and then {@link OAuth2AuthorizationCodeRequestAuthenticationToken#getScopes()}.
+ * If validation fails, an {@link OAuth2AuthorizationCodeRequestAuthenticationException} is thrown.
+ *
+ * @author Joe Grandja
+ * @since 0.4.0
+ * @see OAuth2AuthorizationCodeRequestAuthenticationContext
+ * @see OAuth2AuthorizationCodeRequestAuthenticationToken
+ * @see OAuth2AuthorizationCodeRequestAuthenticationProvider#setAuthenticationValidator(Consumer)
+ */
+public final class OAuth2AuthorizationCodeRequestAuthenticationValidator implements Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> {
+	private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1";
+
+	/**
+	 * The default validator for {@link OAuth2AuthorizationCodeRequestAuthenticationToken#getScopes()}.
+	 */
+	public static final Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> DEFAULT_SCOPE_VALIDATOR =
+			OAuth2AuthorizationCodeRequestAuthenticationValidator::validateScope;
+
+	/**
+	 * The default validator for {@link OAuth2AuthorizationCodeRequestAuthenticationToken#getRedirectUri()}.
+	 */
+	public static final Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> DEFAULT_REDIRECT_URI_VALIDATOR =
+			OAuth2AuthorizationCodeRequestAuthenticationValidator::validateRedirectUri;
+
+	private final Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> authenticationValidator =
+			DEFAULT_REDIRECT_URI_VALIDATOR.andThen(DEFAULT_SCOPE_VALIDATOR);
+
+	@Override
+	public void accept(OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) {
+		this.authenticationValidator.accept(authenticationContext);
+	}
+
+	private static void validateScope(OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) {
+		OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication =
+				authenticationContext.getAuthentication();
+		RegisteredClient registeredClient = authenticationContext.getRegisteredClient();
+
+		Set<String> requestedScopes = authorizationCodeRequestAuthentication.getScopes();
+		Set<String> allowedScopes = registeredClient.getScopes();
+		if (!requestedScopes.isEmpty() && !allowedScopes.containsAll(requestedScopes)) {
+			throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE,
+					authorizationCodeRequestAuthentication, registeredClient);
+		}
+	}
+
+	private static void validateRedirectUri(OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) {
+		OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication =
+				authenticationContext.getAuthentication();
+		RegisteredClient registeredClient = authenticationContext.getRegisteredClient();
+
+		String requestedRedirectUri = authorizationCodeRequestAuthentication.getRedirectUri();
+
+		if (StringUtils.hasText(requestedRedirectUri)) {
+			// ***** redirect_uri is available in authorization request
+
+			UriComponents requestedRedirect = null;
+			try {
+				requestedRedirect = UriComponentsBuilder.fromUriString(requestedRedirectUri).build();
+			} catch (Exception ex) { }
+			if (requestedRedirect == null || requestedRedirect.getFragment() != null) {
+				throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI,
+						authorizationCodeRequestAuthentication, registeredClient);
+			}
+
+			String requestedRedirectHost = requestedRedirect.getHost();
+			if (requestedRedirectHost == null || requestedRedirectHost.equals("localhost")) {
+				// As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-01#section-9.7.1
+				// While redirect URIs using localhost (i.e., "http://localhost:{port}/{path}")
+				// function similarly to loopback IP redirects described in Section 10.3.3,
+				// the use of "localhost" is NOT RECOMMENDED.
+				OAuth2Error error = new OAuth2Error(
+						OAuth2ErrorCodes.INVALID_REQUEST,
+						"localhost is not allowed for the redirect_uri (" + requestedRedirectUri + "). " +
+								"Use the IP literal (127.0.0.1) instead.",
+						"https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-01#section-9.7.1");
+				throwError(error, OAuth2ParameterNames.REDIRECT_URI,
+						authorizationCodeRequestAuthentication, registeredClient);
+			}
+
+			if (!isLoopbackAddress(requestedRedirectHost)) {
+				// As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-01#section-9.7
+				// When comparing client redirect URIs against pre-registered URIs,
+				// authorization servers MUST utilize exact string matching.
+				if (!registeredClient.getRedirectUris().contains(requestedRedirectUri)) {
+					throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI,
+							authorizationCodeRequestAuthentication, registeredClient);
+				}
+			} else {
+				// As per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-01#section-10.3.3
+				// The authorization server MUST allow any port to be specified at the
+				// time of the request for loopback IP redirect URIs, to accommodate
+				// clients that obtain an available ephemeral port from the operating
+				// system at the time of the request.
+				boolean validRedirectUri = false;
+				for (String registeredRedirectUri : registeredClient.getRedirectUris()) {
+					UriComponentsBuilder registeredRedirect = UriComponentsBuilder.fromUriString(registeredRedirectUri);
+					registeredRedirect.port(requestedRedirect.getPort());
+					if (registeredRedirect.build().toString().equals(requestedRedirect.toString())) {
+						validRedirectUri = true;
+						break;
+					}
+				}
+				if (!validRedirectUri) {
+					throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI,
+							authorizationCodeRequestAuthentication, registeredClient);
+				}
+			}
+
+		} else {
+			// ***** redirect_uri is NOT available in authorization request
+
+			if (authorizationCodeRequestAuthentication.getScopes().contains(OidcScopes.OPENID) ||
+					registeredClient.getRedirectUris().size() != 1) {
+				// redirect_uri is REQUIRED for OpenID Connect
+				throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI,
+						authorizationCodeRequestAuthentication, registeredClient);
+			}
+		}
+	}
+
+	private static boolean isLoopbackAddress(String host) {
+		// IPv6 loopback address should either be "0:0:0:0:0:0:0:1" or "::1"
+		if ("[0:0:0:0:0:0:0:1]".equals(host) || "[::1]".equals(host)) {
+			return true;
+		}
+		// IPv4 loopback address ranges from 127.0.0.1 to 127.255.255.255
+		String[] ipv4Octets = host.split("\\.");
+		if (ipv4Octets.length != 4) {
+			return false;
+		}
+		try {
+			int[] address = new int[ipv4Octets.length];
+			for (int i=0; i < ipv4Octets.length; i++) {
+				address[i] = Integer.parseInt(ipv4Octets[i]);
+			}
+			return address[0] == 127 && address[1] >= 0 && address[1] <= 255 && address[2] >= 0 &&
+					address[2] <= 255 && address[3] >= 1 && address[3] <= 255;
+		} catch (NumberFormatException ex) {
+			return false;
+		}
+	}
+
+	private static void throwError(String errorCode, String parameterName,
+			OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication,
+			RegisteredClient registeredClient) {
+		OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, ERROR_URI);
+		throwError(error, parameterName, authorizationCodeRequestAuthentication, registeredClient);
+	}
+
+	private static void throwError(OAuth2Error error, String parameterName,
+			OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication,
+			RegisteredClient registeredClient) {
+
+		boolean redirectOnError = true;
+		if (error.getErrorCode().equals(OAuth2ErrorCodes.INVALID_REQUEST) &&
+				parameterName.equals(OAuth2ParameterNames.REDIRECT_URI)) {
+			redirectOnError = false;
+		}
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = authorizationCodeRequestAuthentication;
+
+		if (redirectOnError && !StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) {
+			String redirectUri = registeredClient.getRedirectUris().iterator().next();
+			authorizationCodeRequestAuthenticationResult = from(authorizationCodeRequestAuthentication)
+					.redirectUri(redirectUri)
+					.build();
+		} else if (!redirectOnError && StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) {
+			authorizationCodeRequestAuthenticationResult = from(authorizationCodeRequestAuthentication)
+					.redirectUri(null)		// Prevent redirects
+					.build();
+		}
+
+		authorizationCodeRequestAuthenticationResult.setAuthenticated(authorizationCodeRequestAuthentication.isAuthenticated());
+
+		throw new OAuth2AuthorizationCodeRequestAuthenticationException(error, authorizationCodeRequestAuthenticationResult);
+	}
+
+	private static OAuth2AuthorizationCodeRequestAuthenticationToken.Builder from(OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication) {
+		return OAuth2AuthorizationCodeRequestAuthenticationToken.with(authorizationCodeRequestAuthentication.getClientId(), (Authentication) authorizationCodeRequestAuthentication.getPrincipal())
+				.authorizationUri(authorizationCodeRequestAuthentication.getAuthorizationUri())
+				.redirectUri(authorizationCodeRequestAuthentication.getRedirectUri())
+				.scopes(authorizationCodeRequestAuthentication.getScopes())
+				.state(authorizationCodeRequestAuthentication.getState())
+				.additionalParameters(authorizationCodeRequestAuthentication.getAdditionalParameters())
+				.authorizationCode(authorizationCodeRequestAuthentication.getAuthorizationCode());
+	}
+
+}

+ 7 - 12
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java

@@ -22,7 +22,6 @@ import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Consumer;
-import java.util.function.Function;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -60,7 +59,6 @@ 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.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -128,10 +126,10 @@ public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests {
 	}
 
 	@Test
-	public void setAuthenticationValidatorResolverWhenNullThenThrowIllegalArgumentException() {
-		assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidatorResolver(null))
+	public void setAuthenticationValidatorWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidator(null))
 				.isInstanceOf(IllegalArgumentException.class)
-				.hasMessage("authenticationValidatorResolver cannot be null");
+				.hasMessage("authenticationValidator cannot be null");
 	}
 
 	@Test
@@ -555,14 +553,14 @@ public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests {
 	}
 
 	@Test
-	public void authenticateWhenCustomAuthenticationValidatorResolverThenUsed() {
+	public void authenticateWhenCustomAuthenticationValidatorThenUsed() {
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
 		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
 				.thenReturn(registeredClient);
 
 		@SuppressWarnings("unchecked")
-		Function<String, OAuth2AuthenticationValidator> authenticationValidatorResolver = mock(Function.class);
-		this.authenticationProvider.setAuthenticationValidatorResolver(authenticationValidatorResolver);
+		Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> authenticationValidator = mock(Consumer.class);
+		this.authenticationProvider.setAuthenticationValidator(authenticationValidator);
 
 		OAuth2AuthorizationCodeRequestAuthenticationToken authentication =
 				authorizationCodeRequestAuthentication(registeredClient, this.principal)
@@ -573,10 +571,7 @@ public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests {
 
 		assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication, authenticationResult);
 
-		ArgumentCaptor<String> parameterNameCaptor = ArgumentCaptor.forClass(String.class);
-		verify(authenticationValidatorResolver, times(2)).apply(parameterNameCaptor.capture());
-		assertThat(parameterNameCaptor.getAllValues()).containsExactly(
-				OAuth2ParameterNames.REDIRECT_URI, OAuth2ParameterNames.SCOPE);
+		verify(authenticationValidator).accept(any());
 	}
 
 	private void assertAuthorizationCodeRequestWithAuthorizationCodeResult(