diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/authentication/OAuth2AuthenticationContext.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/authentication/OAuth2AuthenticationContext.java new file mode 100644 index 00000000..7d8341aa --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/authentication/OAuth2AuthenticationContext.java @@ -0,0 +1,76 @@ +/* + * 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.core.authentication; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.context.Context; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * A context that holds an {@link Authentication} and (optionally) additional information + * and is used by an {@link OAuth2AuthenticationValidator} when attempting to validate the {@link Authentication}. + * + * @author Joe Grandja + * @since 0.2.0 + * @see Context + * @see OAuth2AuthenticationValidator + */ +public final class OAuth2AuthenticationContext implements Context { + private final Map context; + + /** + * Constructs an {@code OAuth2AuthenticationContext} using the provided parameters. + * + * @param authentication the {@code Authentication} + * @param context a {@code Map} of additional context information + */ + public OAuth2AuthenticationContext(Authentication authentication, @Nullable Map context) { + Assert.notNull(authentication, "authentication cannot be null"); + this.context = new HashMap<>(); + if (!CollectionUtils.isEmpty(context)) { + this.context.putAll(context); + } + this.context.put(Authentication.class, authentication); + } + + /** + * Returns the {@link Authentication} associated to the authentication context. + * + * @param the type of the {@code Authentication} + * @return the {@link Authentication} + */ + @SuppressWarnings("unchecked") + public T getAuthentication() { + return (T) get(Authentication.class); + } + + @SuppressWarnings("unchecked") + @Override + public V get(Object key) { + return (V) this.context.get(key); + } + + @Override + public boolean hasKey(Object key) { + return this.context.containsKey(key); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/authentication/OAuth2AuthenticationValidator.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/authentication/OAuth2AuthenticationValidator.java new file mode 100644 index 00000000..edcd1294 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/authentication/OAuth2AuthenticationValidator.java @@ -0,0 +1,40 @@ +/* + * 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.core.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; + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java index 2357ef6f..7f7d4948 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java @@ -20,8 +20,11 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; 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.Function; import java.util.regex.Pattern; import org.springframework.security.authentication.AnonymousAuthenticationToken; @@ -34,6 +37,8 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.OAuth2TokenType; +import org.springframework.security.oauth2.core.authentication.OAuth2AuthenticationContext; +import org.springframework.security.oauth2.core.authentication.OAuth2AuthenticationValidator; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; @@ -68,11 +73,14 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen private static final String PKCE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1"; private static final Pattern LOOPBACK_ADDRESS_PATTERN = Pattern.compile("^127(?:\\.[0-9]+){0,2}\\.[0-9]+$|^\\[(?:0*:)*?:?0*1]$"); + private static final Function DEFAULT_AUTHENTICATION_VALIDATOR_RESOLVER = + createDefaultAuthenticationValidatorResolver(); private final RegisteredClientRepository registeredClientRepository; private final OAuth2AuthorizationService authorizationService; private final OAuth2AuthorizationConsentService authorizationConsentService; private final StringKeyGenerator codeGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96); private final StringKeyGenerator stateGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder()); + private Function authenticationValidatorResolver = DEFAULT_AUTHENTICATION_VALIDATOR_RESOLVER; /** * Constructs an {@code OAuth2AuthorizationCodeRequestAuthenticationProvider} using the provided parameters. @@ -106,6 +114,26 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen return OAuth2AuthorizationCodeRequestAuthenticationToken.class.isAssignableFrom(authentication); } + /** + * Sets the resolver that resolves an {@link OAuth2AuthenticationValidator} from the provided OAuth 2.0 Authorization Request parameter. + * + *

+ * The following OAuth 2.0 Authorization Request parameters are supported: + *

    + *
  1. {@link OAuth2ParameterNames#REDIRECT_URI}
  2. + *
  3. {@link OAuth2ParameterNames#SCOPE}
  4. + *
+ * + *

+ * NOTE: 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 + */ + public void setAuthenticationValidatorResolver(Function authenticationValidatorResolver) { + Assert.notNull(authenticationValidatorResolver, "authenticationValidatorResolver cannot be null"); + this.authenticationValidatorResolver = authenticationValidatorResolver; + } + private Authentication authenticateAuthorizationRequest(Authentication authentication) throws AuthenticationException { OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication; @@ -117,29 +145,21 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen authorizationCodeRequestAuthentication, null); } - if (StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) { - if (!isValidRedirectUri(authorizationCodeRequestAuthentication.getRedirectUri(), registeredClient)) { - throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, - authorizationCodeRequestAuthentication, registeredClient); - } - } else 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); - } + Map context = new HashMap<>(); + context.put(RegisteredClient.class, registeredClient); + OAuth2AuthenticationContext authenticationContext = new OAuth2AuthenticationContext( + authorizationCodeRequestAuthentication, context); + + OAuth2AuthenticationValidator redirectUriValidator = resolveAuthenticationValidator(OAuth2ParameterNames.REDIRECT_URI); + redirectUriValidator.validate(authenticationContext); if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE)) { throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID, authorizationCodeRequestAuthentication, registeredClient); } - Set requestedScopes = authorizationCodeRequestAuthentication.getScopes(); - Set allowedScopes = registeredClient.getScopes(); - if (!requestedScopes.isEmpty() && !allowedScopes.containsAll(requestedScopes)) { - throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, - 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); @@ -170,7 +190,7 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen .authorizationUri(authorizationCodeRequestAuthentication.getAuthorizationUri()) .clientId(registeredClient.getClientId()) .redirectUri(authorizationCodeRequestAuthentication.getRedirectUri()) - .scopes(requestedScopes) + .scopes(authorizationCodeRequestAuthentication.getScopes()) .state(authorizationCodeRequestAuthentication.getState()) .additionalParameters(authorizationCodeRequestAuthentication.getAdditionalParameters()) .build(); @@ -227,6 +247,13 @@ 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 OAuth2AuthorizationCode createAuthorizationCode() { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live @@ -329,6 +356,13 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen .build(); } + private static Function createDefaultAuthenticationValidatorResolver() { + Map 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) @@ -474,4 +508,45 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen .authorizationCode(authorizationCodeRequestAuthentication.getAuthorizationCode()); } + private static class DefaultRedirectUriOAuth2AuthenticationValidator implements OAuth2AuthenticationValidator { + + @Override + public void validate(OAuth2AuthenticationContext authenticationContext) { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = + authenticationContext.getAuthentication(); + RegisteredClient registeredClient = authenticationContext.get(RegisteredClient.class); + + if (StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) { + if (!isValidRedirectUri(authorizationCodeRequestAuthentication.getRedirectUri(), registeredClient)) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, + authorizationCodeRequestAuthentication, registeredClient); + } + } else 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 class DefaultScopeOAuth2AuthenticationValidator implements OAuth2AuthenticationValidator { + + @Override + public void validate(OAuth2AuthenticationContext authenticationContext) { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = + authenticationContext.getAuthentication(); + RegisteredClient registeredClient = authenticationContext.get(RegisteredClient.class); + + Set requestedScopes = authorizationCodeRequestAuthentication.getScopes(); + Set allowedScopes = registeredClient.getScopes(); + if (!requestedScopes.isEmpty() && !allowedScopes.containsAll(requestedScopes)) { + throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, + authorizationCodeRequestAuthentication, registeredClient); + } + } + + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java index e644867b..60932757 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.function.Function; import org.junit.Before; import org.junit.Test; @@ -32,6 +33,7 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.OAuth2TokenType; +import org.springframework.security.oauth2.core.authentication.OAuth2AuthenticationValidator; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; @@ -54,6 +56,7 @@ 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; @@ -110,6 +113,13 @@ public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests { assertThat(this.authenticationProvider.supports(OAuth2AuthorizationCodeRequestAuthenticationToken.class)).isTrue(); } + @Test + public void setAuthenticationValidatorResolverWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidatorResolver(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationValidatorResolver cannot be null"); + } + @Test public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); @@ -477,6 +487,31 @@ public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests { assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication, authenticationResult); } + @Test + public void authenticateWhenCustomAuthenticationValidatorResolverThenUsed() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + @SuppressWarnings("unchecked") + Function authenticationValidatorResolver = mock(Function.class); + this.authenticationProvider.setAuthenticationValidatorResolver(authenticationValidatorResolver); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication, authenticationResult); + + ArgumentCaptor parameterNameCaptor = ArgumentCaptor.forClass(String.class); + verify(authenticationValidatorResolver, times(2)).apply(parameterNameCaptor.capture()); + assertThat(parameterNameCaptor.getAllValues()).containsExactly( + OAuth2ParameterNames.REDIRECT_URI, OAuth2ParameterNames.SCOPE); + } + private void assertAuthorizationCodeRequestWithAuthorizationCodeResult( RegisteredClient registeredClient, OAuth2AuthorizationCodeRequestAuthenticationToken authentication,