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 9b0da659..b833cf06 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 @@ -32,6 +32,7 @@ import org.springframework.security.config.annotation.web.configurers.ExceptionH import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenIntrospectionAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenRevocationAuthenticationProvider; @@ -221,6 +222,13 @@ public final class OAuth2AuthorizationServerConfigurerSection 4.1.1 Authorization Request + */ +public class OAuth2AuthorizationCodeRequestAuthenticationProvider implements AuthenticationProvider { + private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); + 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 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()); + + /** + * Constructs an {@code OAuth2AuthorizationCodeRequestAuthenticationProvider} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authorizationService the authorization service + * @param authorizationConsentService the authorization consent service + */ + public OAuth2AuthorizationCodeRequestAuthenticationProvider(RegisteredClientRepository registeredClientRepository, + OAuth2AuthorizationService authorizationService, OAuth2AuthorizationConsentService authorizationConsentService) { + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null"); + this.registeredClientRepository = registeredClientRepository; + this.authorizationService = authorizationService; + this.authorizationConsentService = authorizationConsentService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = + (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication; + + return authorizationCodeRequestAuthentication.isConsent() ? + authenticateAuthorizationConsent(authentication) : + authenticateAuthorizationRequest(authentication); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2AuthorizationCodeRequestAuthenticationToken.class.isAssignableFrom(authentication); + } + + private Authentication authenticateAuthorizationRequest(Authentication authentication) throws AuthenticationException { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = + (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication; + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId( + authorizationCodeRequestAuthentication.getClientId()); + if (registeredClient == null) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, + 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); + } + + 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); + } + + // code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE) + String codeChallenge = (String) authorizationCodeRequestAuthentication.getAdditionalParameters().get(PkceParameterNames.CODE_CHALLENGE); + if (StringUtils.hasText(codeChallenge)) { + String codeChallengeMethod = (String) authorizationCodeRequestAuthentication.getAdditionalParameters().get(PkceParameterNames.CODE_CHALLENGE_METHOD); + if (StringUtils.hasText(codeChallengeMethod)) { + if (!"S256".equals(codeChallengeMethod) && !"plain".equals(codeChallengeMethod)) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI, + authorizationCodeRequestAuthentication, registeredClient, null); + } + } + } else if (registeredClient.getClientSettings().requireProofKey()) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI, + authorizationCodeRequestAuthentication, registeredClient, null); + } + + // --------------- + // The request is valid - ensure the resource owner is authenticated + // --------------- + + Authentication principal = (Authentication) authorizationCodeRequestAuthentication.getPrincipal(); + if (!isPrincipalAuthenticated(principal)) { + // Return the authorization request as-is where isAuthenticated() is false + return authorizationCodeRequestAuthentication; + } + + OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(authorizationCodeRequestAuthentication.getAuthorizationUri()) + .clientId(registeredClient.getClientId()) + .redirectUri(authorizationCodeRequestAuthentication.getRedirectUri()) + .scopes(requestedScopes) + .state(authorizationCodeRequestAuthentication.getState()) + .additionalParameters(authorizationCodeRequestAuthentication.getAdditionalParameters()) + .build(); + + OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( + registeredClient.getId(), principal.getName()); + + if (requireAuthorizationConsent(registeredClient, authorizationRequest, currentAuthorizationConsent)) { + String state = this.stateGenerator.generateKey(); + OAuth2Authorization authorization = authorizationBuilder(registeredClient, principal, authorizationRequest) + .attribute(OAuth2ParameterNames.STATE, state) + .build(); + this.authorizationService.save(authorization); + + // TODO Need to remove 'in-flight' authorization if consent step is not completed (e.g. approved or cancelled) + + Set currentAuthorizedScopes = currentAuthorizationConsent != null ? + currentAuthorizationConsent.getScopes() : null; + + return OAuth2AuthorizationCodeRequestAuthenticationToken.with(registeredClient.getClientId(), principal) + .authorizationUri(authorizationRequest.getAuthorizationUri()) + .scopes(currentAuthorizedScopes) + .state(state) + .consentRequired(true) + .build(); + } + + OAuth2AuthorizationCode authorizationCode = createAuthorizationCode(); + OAuth2Authorization authorization = authorizationBuilder(registeredClient, principal, authorizationRequest) + .token(authorizationCode) + .attribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME, authorizationRequest.getScopes()) + .build(); + this.authorizationService.save(authorization); + +// TODO security checks for code parameter +// The authorization code MUST expire shortly after it is issued to mitigate the risk of leaks. +// A maximum authorization code lifetime of 10 minutes is RECOMMENDED. +// The client MUST NOT use the authorization code more than once. +// If an authorization code is used more than once, the authorization server MUST deny the request +// and SHOULD revoke (when possible) all tokens previously issued based on that authorization code. +// The authorization code is bound to the client identifier and redirection URI. + + String redirectUri = authorizationRequest.getRedirectUri(); + if (!StringUtils.hasText(redirectUri)) { + redirectUri = registeredClient.getRedirectUris().iterator().next(); + } + + return OAuth2AuthorizationCodeRequestAuthenticationToken.with(registeredClient.getClientId(), principal) + .authorizationUri(authorizationRequest.getAuthorizationUri()) + .redirectUri(redirectUri) + .scopes(authorizationRequest.getScopes()) + .state(authorizationRequest.getState()) + .authorizationCode(authorizationCode) + .build(); + } + + private OAuth2AuthorizationCode createAuthorizationCode() { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live + return new OAuth2AuthorizationCode(this.codeGenerator.generateKey(), issuedAt, expiresAt); + } + + private Authentication authenticateAuthorizationConsent(Authentication authentication) throws AuthenticationException { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = + (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication; + + OAuth2Authorization authorization = this.authorizationService.findByToken( + authorizationCodeRequestAuthentication.getState(), STATE_TOKEN_TYPE); + if (authorization == null) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE, + authorizationCodeRequestAuthentication, null, null); + } + + // The 'in-flight' authorization must be associated to the current principal + Authentication principal = (Authentication) authorizationCodeRequestAuthentication.getPrincipal(); + if (!isPrincipalAuthenticated(principal) || !principal.getName().equals(authorization.getPrincipalName())) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE, + authorizationCodeRequestAuthentication, null, null); + } + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId( + authorizationCodeRequestAuthentication.getClientId()); + if (registeredClient == null || !registeredClient.getId().equals(authorization.getRegisteredClientId())) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, + authorizationCodeRequestAuthentication, registeredClient); + } + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + Set requestedScopes = authorizationRequest.getScopes(); + Set authorizedScopes = new HashSet<>(authorizationCodeRequestAuthentication.getScopes()); + if (!requestedScopes.containsAll(authorizedScopes)) { + throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, + authorizationCodeRequestAuthentication, registeredClient, authorizationRequest); + } + + OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( + authorization.getRegisteredClientId(), authorization.getPrincipalName()); + Set currentAuthorizedScopes = currentAuthorizationConsent != null ? + currentAuthorizationConsent.getScopes() : Collections.emptySet(); + + if (authorizedScopes.isEmpty() && currentAuthorizedScopes.isEmpty()) { + // Authorization consent denied + this.authorizationService.remove(authorization); + throwError(OAuth2ErrorCodes.ACCESS_DENIED, OAuth2ParameterNames.CLIENT_ID, + authorizationCodeRequestAuthentication, registeredClient, authorizationRequest); + } + + if (requestedScopes.contains(OidcScopes.OPENID)) { + // 'openid' scope is auto-approved as it does not require consent + authorizedScopes.add(OidcScopes.OPENID); + } + + if (!currentAuthorizedScopes.isEmpty()) { + for (String requestedScope : requestedScopes) { + if (currentAuthorizedScopes.contains(requestedScope)) { + authorizedScopes.add(requestedScope); + } + } + } + + if (!authorizedScopes.isEmpty() && !authorizedScopes.equals(currentAuthorizedScopes)) { + OAuth2AuthorizationConsent.Builder authorizationConsentBuilder; + if (currentAuthorizationConsent != null) { + authorizationConsentBuilder = OAuth2AuthorizationConsent.from(currentAuthorizationConsent); + } else { + authorizationConsentBuilder = OAuth2AuthorizationConsent.withId( + authorization.getRegisteredClientId(), authorization.getPrincipalName()); + } + authorizedScopes.forEach(authorizationConsentBuilder::scope); + OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build(); + this.authorizationConsentService.save(authorizationConsent); + } + + OAuth2AuthorizationCode authorizationCode = createAuthorizationCode(); + + OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization) + .token(authorizationCode) + .attributes(attrs -> { + attrs.remove(OAuth2ParameterNames.STATE); + attrs.put(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME, authorizedScopes); + }) + .build(); + this.authorizationService.save(updatedAuthorization); + + String redirectUri = authorizationRequest.getRedirectUri(); + if (!StringUtils.hasText(redirectUri)) { + redirectUri = registeredClient.getRedirectUris().iterator().next(); + } + + return OAuth2AuthorizationCodeRequestAuthenticationToken.with(registeredClient.getClientId(), principal) + .authorizationUri(authorizationRequest.getAuthorizationUri()) + .redirectUri(redirectUri) + .scopes(authorizedScopes) + .state(authorizationRequest.getState()) + .authorizationCode(authorizationCode) + .build(); + } + + private static OAuth2Authorization.Builder authorizationBuilder(RegisteredClient registeredClient, Authentication principal, + OAuth2AuthorizationRequest authorizationRequest) { + return OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(principal.getName()) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .attribute(Principal.class.getName(), principal) + .attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest); + } + + private static boolean requireAuthorizationConsent(RegisteredClient registeredClient, + OAuth2AuthorizationRequest authorizationRequest, OAuth2AuthorizationConsent authorizationConsent) { + + if (!registeredClient.getClientSettings().requireUserConsent()) { + return false; + } + // 'openid' scope does not require consent + if (authorizationRequest.getScopes().contains(OidcScopes.OPENID) && + authorizationRequest.getScopes().size() == 1) { + return false; + } + + if (authorizationConsent != null && + authorizationConsent.getScopes().containsAll(authorizationRequest.getScopes())) { + return false; + } + + return true; + } + + private static boolean isValidRedirectUri(String requestedRedirectUri, RegisteredClient registeredClient) { + UriComponents requestedRedirect; + try { + requestedRedirect = UriComponentsBuilder.fromUriString(requestedRedirectUri).build(); + if (requestedRedirect.getFragment() != null) { + return false; + } + } catch (Exception ex) { + return false; + } + + String requestedRedirectHost = requestedRedirect.getHost(); + if (requestedRedirectHost == null || requestedRedirectHost.equals("localhost")) { + // As per https://tools.ietf.org/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. + return false; + } + if (!LOOPBACK_ADDRESS_PATTERN.matcher(requestedRedirectHost).matches()) { + // As per https://tools.ietf.org/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. + return registeredClient.getRedirectUris().contains(requestedRedirectUri); + } + + // As per https://tools.ietf.org/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. + for (String registeredRedirectUri : registeredClient.getRedirectUris()) { + UriComponentsBuilder registeredRedirect = UriComponentsBuilder.fromUriString(registeredRedirectUri); + registeredRedirect.port(requestedRedirect.getPort()); + if (registeredRedirect.build().toString().equals(requestedRedirect.toString())) { + return true; + } + } + return false; + } + + private static boolean isPrincipalAuthenticated(Authentication principal) { + return principal != null && + !AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) && + principal.isAuthenticated(); + } + + private static void throwError(String errorCode, String parameterName, + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, + RegisteredClient registeredClient) { + throwError(errorCode, parameterName, authorizationCodeRequestAuthentication, registeredClient, null); + } + + private static void throwError(String errorCode, String parameterName, + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, + RegisteredClient registeredClient, OAuth2AuthorizationRequest authorizationRequest) { + throwError(errorCode, parameterName, "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1", + authorizationCodeRequestAuthentication, registeredClient, authorizationRequest); + } + + private static void throwError(String errorCode, String parameterName, String errorUri, + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, + RegisteredClient registeredClient, OAuth2AuthorizationRequest authorizationRequest) { + + boolean redirectOnError = true; + if (errorCode.equals(OAuth2ErrorCodes.INVALID_REQUEST) && + (parameterName.equals(OAuth2ParameterNames.CLIENT_ID) || + parameterName.equals(OAuth2ParameterNames.REDIRECT_URI) || + parameterName.equals(OAuth2ParameterNames.STATE))) { + redirectOnError = false; + } + + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = authorizationCodeRequestAuthentication; + + if (redirectOnError && !StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) { + String redirectUri = resolveRedirectUri(authorizationRequest, registeredClient); + String state = authorizationCodeRequestAuthentication.isConsent() && authorizationRequest != null ? + authorizationRequest.getState() : authorizationCodeRequestAuthentication.getState(); + authorizationCodeRequestAuthenticationResult = from(authorizationCodeRequestAuthentication) + .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()); + } + + OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, errorUri); + throw new OAuth2AuthorizationCodeRequestAuthenticationException(error, authorizationCodeRequestAuthenticationResult); + } + + private static String resolveRedirectUri(OAuth2AuthorizationRequest authorizationRequest, RegisteredClient registeredClient) { + if (authorizationRequest != null && StringUtils.hasText(authorizationRequest.getRedirectUri())) { + return authorizationRequest.getRedirectUri(); + } + if (registeredClient != null) { + return registeredClient.getRedirectUris().iterator().next(); + } + return null; + } + + 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()) + .consentRequired(authorizationCodeRequestAuthentication.isConsentRequired()) + .consent(authorizationCodeRequestAuthentication.isConsent()) + .authorizationCode(authorizationCodeRequestAuthentication.getAuthorizationCode()); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationToken.java new file mode 100644 index 00000000..b6cd2532 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationToken.java @@ -0,0 +1,320 @@ +/* + * 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.authentication; + +import java.io.Serializable; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.Version; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * An {@link Authentication} implementation for the OAuth 2.0 Authorization Request (and Consent) + * used in the Authorization Code Grant. + * + * @author Joe Grandja + * @since 0.1.2 + * @see OAuth2AuthorizationCodeRequestAuthenticationProvider + */ +public final class OAuth2AuthorizationCodeRequestAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + private String authorizationUri; + private String clientId; + private Authentication principal; + private String redirectUri; + private Set scopes; + private String state; + private Map additionalParameters; + private boolean consentRequired; + private boolean consent; + private OAuth2AuthorizationCode authorizationCode; + + private OAuth2AuthorizationCodeRequestAuthenticationToken() { + super(Collections.emptyList()); + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + @Override + public Object getCredentials() { + return ""; + } + + /** + * Returns the authorization URI. + * + * @return the authorization URI + */ + public String getAuthorizationUri() { + return this.authorizationUri; + } + + /** + * Returns the client identifier. + * + * @return the client identifier + */ + public String getClientId() { + return this.clientId; + } + + /** + * Returns the redirect uri. + * + * @return the redirect uri + */ + @Nullable + public String getRedirectUri() { + return this.redirectUri; + } + + /** + * Returns the requested (or authorized) scope(s). + * + * @return the requested (or authorized) scope(s), or an empty {@code Set} if not available + */ + public Set getScopes() { + return this.scopes; + } + + /** + * Returns the state. + * + * @return the state + */ + @Nullable + public String getState() { + return this.state; + } + + /** + * Returns the additional parameters. + * + * @return the additional parameters + */ + public Map getAdditionalParameters() { + return this.additionalParameters; + } + + /** + * Returns {@code true} if authorization consent is required, {@code false} otherwise. + * + * @return {@code true} if authorization consent is required, {@code false} otherwise + */ + public boolean isConsentRequired() { + return this.consentRequired; + } + + /** + * Returns {@code true} if this {@code Authentication} represents an authorization consent request, + * {@code false} otherwise. + * + * @return {@code true} if this {@code Authentication} represents an authorization consent request, {@code false} otherwise + */ + public boolean isConsent() { + return this.consent; + } + + /** + * Returns the {@link OAuth2AuthorizationCode}. + * + * @return the {@link OAuth2AuthorizationCode} + */ + @Nullable + public OAuth2AuthorizationCode getAuthorizationCode() { + return this.authorizationCode; + } + + /** + * Returns a new {@link Builder}, initialized with the given client identifier + * and {@code Principal} (Resource Owner). + * + * @param clientId the client identifier + * @param principal the {@code Principal} (Resource Owner) + * @return the {@link Builder} + */ + public static Builder with(@NonNull String clientId, @NonNull Authentication principal) { + Assert.hasText(clientId, "clientId cannot be empty"); + Assert.notNull(principal, "principal cannot be null"); + return new Builder(clientId, principal); + } + + /** + * A builder for {@link OAuth2AuthorizationCodeRequestAuthenticationToken}. + */ + public static final class Builder implements Serializable { + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + private String authorizationUri; + private String clientId; + private Authentication principal; + private String redirectUri; + private Set scopes; + private String state; + private Map additionalParameters; + private boolean consentRequired; + private boolean consent; + private OAuth2AuthorizationCode authorizationCode; + + private Builder(String clientId, Authentication principal) { + this.clientId = clientId; + this.principal = principal; + } + + /** + * Sets the authorization URI. + * + * @param authorizationUri the authorization URI + * @return the {@link Builder} + */ + public Builder authorizationUri(String authorizationUri) { + this.authorizationUri = authorizationUri; + return this; + } + + /** + * Sets the redirect uri. + * + * @param redirectUri the redirect uri + * @return the {@link Builder} + */ + public Builder redirectUri(String redirectUri) { + this.redirectUri = redirectUri; + return this; + } + + /** + * Sets the requested (or authorized) scope(s). + * + * @param scopes the requested (or authorized) scope(s) + * @return the {@link Builder} + */ + public Builder scopes(Set scopes) { + if (scopes != null) { + this.scopes = new HashSet<>(scopes); + } + return this; + } + + /** + * Sets the state. + * + * @param state the state + * @return the {@link Builder} + */ + public Builder state(String state) { + this.state = state; + return this; + } + + /** + * Sets the additional parameters. + * + * @param additionalParameters the additional parameters + * @return the {@link Builder} + */ + public Builder additionalParameters(Map additionalParameters) { + if (additionalParameters != null) { + this.additionalParameters = new HashMap<>(additionalParameters); + } + return this; + } + + /** + * Set to {@code true} if authorization consent is required, {@code false} otherwise. + * + * @param consentRequired {@code true} if authorization consent is required, {@code false} otherwise + * @return the {@link Builder} + */ + public Builder consentRequired(boolean consentRequired) { + this.consentRequired = consentRequired; + return this; + } + + /** + * Set to {@code true} if this {@code Authentication} represents an authorization consent request, {@code false} otherwise. + * + * @param consent {@code true} if this {@code Authentication} represents an authorization consent request, {@code false} otherwise + * @return the {@link Builder} + */ + public Builder consent(boolean consent) { + this.consent = consent; + return this; + } + + /** + * Sets the {@link OAuth2AuthorizationCode}. + * + * @param authorizationCode the {@link OAuth2AuthorizationCode} + * @return the {@link Builder} + */ + public Builder authorizationCode(OAuth2AuthorizationCode authorizationCode) { + this.authorizationCode = authorizationCode; + return this; + } + + /** + * Builds a new {@link OAuth2AuthorizationCodeRequestAuthenticationToken}. + * + * @return the {@link OAuth2AuthorizationCodeRequestAuthenticationToken} + */ + public OAuth2AuthorizationCodeRequestAuthenticationToken build() { + Assert.hasText(this.authorizationUri, "authorizationUri cannot be empty"); + if (this.consent) { + Assert.hasText(this.state, "state cannot be empty"); + } + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + new OAuth2AuthorizationCodeRequestAuthenticationToken(); + + authentication.authorizationUri = this.authorizationUri; + authentication.clientId = this.clientId; + authentication.principal = this.principal; + authentication.redirectUri = this.redirectUri; + authentication.scopes = Collections.unmodifiableSet( + !CollectionUtils.isEmpty(this.scopes) ? + this.scopes : + Collections.emptySet()); + authentication.state = this.state; + authentication.additionalParameters = Collections.unmodifiableMap( + !CollectionUtils.isEmpty(this.additionalParameters) ? + this.additionalParameters : + Collections.emptyMap()); + authentication.consentRequired = this.consentRequired; + authentication.consent = this.consent; + authentication.authorizationCode = this.authorizationCode; + if (this.authorizationCode != null || this.consentRequired) { + authentication.setAuthenticated(true); + } + + return authentication; + } + + } + +} 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 7876146d..bd13a6d2 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 @@ -17,16 +17,11 @@ package org.springframework.security.oauth2.server.authorization.web; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.security.Principal; -import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.Arrays; -import java.util.Base64; -import java.util.Collections; import java.util.HashSet; -import java.util.List; +import java.util.Map; import java.util.Set; -import java.util.regex.Pattern; +import java.util.stream.Collectors; import javax.servlet.FilterChain; import javax.servlet.ServletException; @@ -37,59 +32,53 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; -import org.springframework.security.crypto.keygen.StringKeyGenerator; -import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; -import org.springframework.security.oauth2.core.OAuth2TokenType; -import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; 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.OAuth2AuthorizationCodeRequestAuthenticationException; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; -import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.web.DefaultRedirectStrategy; import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.util.RedirectUrlBuilder; +import org.springframework.security.web.util.UrlUtils; import org.springframework.security.web.util.matcher.AndRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.NegatedRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; -import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; /** * A {@code Filter} for the OAuth 2.0 Authorization Code Grant, - * which handles the processing of the OAuth 2.0 Authorization Request. + * which handles the processing of the OAuth 2.0 Authorization Request (and Consent). * * @author Joe Grandja * @author Paurav Munshi * @author Daniel Garnier-Moiroux * @author Anoop Garlapati * @since 0.0.1 - * @see RegisteredClientRepository - * @see OAuth2AuthorizationService - * @see OAuth2AuthorizationConsentService - * @see OAuth2Authorization - * @see OAuth2AuthorizationConsent - * @see Section 4.1 Authorization Code Grant - * @see Section 4.1.1 Authorization Request - * @see Section 4.1.2 Authorization Response + * @see AuthenticationManager + * @see OAuth2AuthorizationCodeRequestAuthenticationProvider + * @see Section 4.1 Authorization Code Grant + * @see Section 4.1.1 Authorization Request + * @see Section 4.1.2 Authorization Response */ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { /** @@ -97,18 +86,9 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { */ public static final String DEFAULT_AUTHORIZATION_ENDPOINT_URI = "/oauth2/authorize"; - private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); - private static final String PKCE_ERROR_URI = "https://tools.ietf.org/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 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 AuthenticationManager authenticationManager; + private final RequestMatcher authorizationEndpointMatcher; + private final AuthenticationConverter authenticationConverter; private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); private String userConsentUri; @@ -117,14 +97,12 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { * * @param registeredClientRepository the repository of registered clients * @param authorizationService the authorization service - * @deprecated use - * {@link #OAuth2AuthorizationEndpointFilter(RegisteredClientRepository, OAuth2AuthorizationService, OAuth2AuthorizationConsentService)} - * instead. + * @deprecated use {@link #OAuth2AuthorizationEndpointFilter(AuthenticationManager)} instead. */ @Deprecated public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository, OAuth2AuthorizationService authorizationService) { - this(registeredClientRepository, authorizationService, new InMemoryOAuth2AuthorizationConsentService()); + this(null); } /** @@ -133,47 +111,38 @@ 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 use {@link #OAuth2AuthorizationEndpointFilter(AuthenticationManager, String)} instead. */ @Deprecated public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository, OAuth2AuthorizationService authorizationService, String authorizationEndpointUri) { - this(registeredClientRepository, authorizationService, new InMemoryOAuth2AuthorizationConsentService(), authorizationEndpointUri); + this(null, 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 + * @param authenticationManager the authentication manager */ - public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository, - OAuth2AuthorizationService authorizationService, OAuth2AuthorizationConsentService authorizationConsentService) { - this(registeredClientRepository, authorizationService, authorizationConsentService, DEFAULT_AUTHORIZATION_ENDPOINT_URI); + public OAuth2AuthorizationEndpointFilter(AuthenticationManager authenticationManager) { + this(authenticationManager, 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 authorizationConsentService the authorization consent service + * @param authenticationManager the authentication manager * @param authorizationEndpointUri the endpoint {@code URI} for authorization requests */ - public OAuth2AuthorizationEndpointFilter(RegisteredClientRepository registeredClientRepository, - OAuth2AuthorizationService authorizationService, OAuth2AuthorizationConsentService authorizationConsentService, - String authorizationEndpointUri) { - Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); - Assert.notNull(authorizationService, "authorizationService cannot be null"); - Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null"); + public OAuth2AuthorizationEndpointFilter(AuthenticationManager authenticationManager, String authorizationEndpointUri) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); Assert.hasText(authorizationEndpointUri, "authorizationEndpointUri cannot be empty"); - this.registeredClientRepository = registeredClientRepository; - this.authorizationService = authorizationService; - this.authorizationConsentService = authorizationConsentService; + this.authenticationManager = authenticationManager; + this.authorizationEndpointMatcher = createDefaultRequestMatcher(authorizationEndpointUri); + this.authenticationConverter = new OAuth2AuthorizationCodeRequestAuthenticationConverter(); + } + private static RequestMatcher createDefaultRequestMatcher(String authorizationEndpointUri) { RequestMatcher authorizationRequestGetMatcher = new AntPathRequestMatcher( authorizationEndpointUri, HttpMethod.GET.name()); RequestMatcher authorizationRequestPostMatcher = new AntPathRequestMatcher( @@ -182,15 +151,17 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { String scope = request.getParameter(OAuth2ParameterNames.SCOPE); return StringUtils.hasText(scope) && scope.contains(OidcScopes.OPENID); }; - RequestMatcher consentActionMatcher = request -> - request.getParameter(UserConsentPage.CONSENT_ACTION_PARAMETER_NAME) != null; - this.authorizationRequestMatcher = new OrRequestMatcher( + RequestMatcher responseTypeParameterMatcher = request -> + request.getParameter(OAuth2ParameterNames.RESPONSE_TYPE) != null; + + RequestMatcher authorizationRequestMatcher = new OrRequestMatcher( authorizationRequestGetMatcher, new AndRequestMatcher( - authorizationRequestPostMatcher, openidScopeMatcher, - new NegatedRequestMatcher(consentActionMatcher))); - this.userConsentMatcher = new AndRequestMatcher( - authorizationRequestPostMatcher, consentActionMatcher); + authorizationRequestPostMatcher, responseTypeParameterMatcher, openidScopeMatcher)); + RequestMatcher authorizationConsentMatcher = new AndRequestMatcher( + authorizationRequestPostMatcher, new NegatedRequestMatcher(responseTypeParameterMatcher)); + + return new OrRequestMatcher(authorizationRequestMatcher, authorizationConsentMatcher); } /** @@ -208,370 +179,108 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (this.authorizationRequestMatcher.matches(request)) { - processAuthorizationRequest(request, response, filterChain); - } else if (this.userConsentMatcher.matches(request)) { - processUserConsent(request, response); - } else { - filterChain.doFilter(request, response); - } - } - - private void processAuthorizationRequest(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - - OAuth2AuthorizationRequestContext authorizationRequestContext = - new OAuth2AuthorizationRequestContext( - request.getRequestURL().toString(), - OAuth2EndpointUtils.getParameters(request)); - - validateAuthorizationRequest(authorizationRequestContext); - - if (authorizationRequestContext.hasError()) { - if (authorizationRequestContext.isRedirectOnError()) { - sendErrorResponse(request, response, authorizationRequestContext.resolveRedirectUri(), - authorizationRequestContext.getError(), authorizationRequestContext.getState()); - } else { - sendErrorResponse(response, authorizationRequestContext.getError()); - } - return; - } - - // --------------- - // The request is valid - ensure the resource owner is authenticated - // --------------- - - Authentication principal = SecurityContextHolder.getContext().getAuthentication(); - if (!isPrincipalAuthenticated(principal)) { - // Pass through the chain with the expectation that the authentication process - // will commence via AuthenticationEntryPoint + if (!this.authorizationEndpointMatcher.matches(request)) { filterChain.doFilter(request, response); return; } - RegisteredClient registeredClient = authorizationRequestContext.getRegisteredClient(); - OAuth2AuthorizationRequest authorizationRequest = authorizationRequestContext.buildAuthorizationRequest(); - OAuth2Authorization.Builder builder = OAuth2Authorization.withRegisteredClient(registeredClient) - .principalName(principal.getName()) - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .attribute(Principal.class.getName(), principal) - .attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest); + try { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationConverter.convert(request); - OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( - registeredClient.getId(), principal.getName()); - if (requireUserConsent(registeredClient, authorizationRequest, currentAuthorizationConsent)) { - String state = this.stateGenerator.generateKey(); - OAuth2Authorization authorization = builder - .attribute(OAuth2ParameterNames.STATE, state) - .build(); - this.authorizationService.save(authorization); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationManager.authenticate(authorizationCodeRequestAuthentication); - // TODO Need to remove 'in-flight' authorization if consent step is not completed (e.g. approved or cancelled) - - if (hasCustomUserConsentPage()) { - String redirectUri = 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, redirectUri); - } else { - UserConsentPage.displayConsent(request, response, registeredClient, authorization, currentAuthorizationConsent); + if (!authorizationCodeRequestAuthenticationResult.isAuthenticated()) { + // If the Principal (Resource Owner) is not authenticated then + // pass through the chain with the expectation that the authentication process + // will commence via AuthenticationEntryPoint + filterChain.doFilter(request, response); + return; } - } else { - Instant issuedAt = Instant.now(); - Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live - OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode( - this.codeGenerator.generateKey(), issuedAt, expiresAt); - OAuth2Authorization authorization = builder - .token(authorizationCode) - .attribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME, authorizationRequest.getScopes()) - .build(); - this.authorizationService.save(authorization); -// TODO security checks for code parameter -// The authorization code MUST expire shortly after it is issued to mitigate the risk of leaks. -// A maximum authorization code lifetime of 10 minutes is RECOMMENDED. -// The client MUST NOT use the authorization code more than once. -// If an authorization code is used more than once, the authorization server MUST deny the request -// and SHOULD revoke (when possible) all tokens previously issued based on that authorization code. -// The authorization code is bound to the client identifier and redirection URI. + if (authorizationCodeRequestAuthenticationResult.isConsentRequired()) { + sendAuthorizationConsent(request, response, authorizationCodeRequestAuthentication, authorizationCodeRequestAuthenticationResult); + return; + } - sendAuthorizationResponse(request, response, - authorizationRequestContext.resolveRedirectUri(), authorizationCode, authorizationRequest.getState()); + sendAuthorizationResponse(request, response, authorizationCodeRequestAuthenticationResult); + + } catch (OAuth2AuthenticationException ex) { + SecurityContextHolder.clearContext(); + sendErrorResponse(request, response, ex); } } - private boolean hasCustomUserConsentPage() { + private void sendAuthorizationConsent(HttpServletRequest request, HttpServletResponse response, + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult) throws IOException { + + String clientId = authorizationCodeRequestAuthenticationResult.getClientId(); + Authentication principal = (Authentication) authorizationCodeRequestAuthenticationResult.getPrincipal(); + Set requestedScopes = authorizationCodeRequestAuthentication.getScopes(); + Set authorizedScopes = authorizationCodeRequestAuthenticationResult.getScopes(); + String state = authorizationCodeRequestAuthenticationResult.getState(); + + if (hasConsentUri()) { + String redirectUri = UriComponentsBuilder.fromUriString(resolveConsentUri(request)) + .queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes)) + .queryParam(OAuth2ParameterNames.CLIENT_ID, clientId) + .queryParam(OAuth2ParameterNames.STATE, state) + .toUriString(); + this.redirectStrategy.sendRedirect(request, response, redirectUri); + } else { + UserConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state); + } + } + + private boolean hasConsentUri() { return StringUtils.hasText(this.userConsentUri); } - private static boolean requireUserConsent(RegisteredClient registeredClient, OAuth2AuthorizationRequest authorizationRequest, - OAuth2AuthorizationConsent currentAuthorizationConsent) { - - if (!registeredClient.getClientSettings().requireUserConsent()) { - return false; - } - // openid scope does not require consent - if (authorizationRequest.getScopes().contains(OidcScopes.OPENID) && - authorizationRequest.getScopes().size() == 1) { - return false; - } - - if (currentAuthorizationConsent != null && - currentAuthorizationConsent.getScopes().containsAll(authorizationRequest.getScopes())) { - return false; - } - - return true; - } - - private void processUserConsent(HttpServletRequest request, HttpServletResponse response) - throws IOException { - - UserConsentRequestContext userConsentRequestContext = - new UserConsentRequestContext( - request.getRequestURL().toString(), - OAuth2EndpointUtils.getParameters(request)); - - validateUserConsentRequest(userConsentRequestContext); - - if (userConsentRequestContext.hasError()) { - if (userConsentRequestContext.isRedirectOnError()) { - sendErrorResponse(request, response, userConsentRequestContext.resolveRedirectUri(), - userConsentRequestContext.getError(), userConsentRequestContext.getState()); - } else { - sendErrorResponse(response, userConsentRequestContext.getError()); - } - return; - } - - if (!UserConsentPage.isConsentApproved(request)) { - this.authorizationService.remove(userConsentRequestContext.getAuthorization()); - OAuth2Error error = createError(OAuth2ErrorCodes.ACCESS_DENIED, OAuth2ParameterNames.CLIENT_ID); - sendErrorResponse(request, response, userConsentRequestContext.resolveRedirectUri(), - error, userConsentRequestContext.getAuthorizationRequest().getState()); - return; - } - - Instant issuedAt = Instant.now(); - Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); // TODO Allow configuration for authorization code time-to-live - OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode( - this.codeGenerator.generateKey(), issuedAt, expiresAt); - Set authorizedScopes = userConsentRequestContext.getScopes(); - if (userConsentRequestContext.getAuthorizationRequest().getScopes().contains(OidcScopes.OPENID)) { - // openid scope is auto-approved as it does not require consent - authorizedScopes.add(OidcScopes.OPENID); - } - - OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( - userConsentRequestContext.getAuthorization().getRegisteredClientId(), - userConsentRequestContext.getAuthorization().getPrincipalName()); - if (currentAuthorizationConsent != null) { - Set currentAuthorizedScopes = currentAuthorizationConsent.getScopes(); - for (String requestedScope : userConsentRequestContext.getAuthorizationRequest().getScopes()) { - if (currentAuthorizedScopes.contains(requestedScope)) { - authorizedScopes.add(requestedScope); - } - } - } - saveAuthorizationConsent(currentAuthorizationConsent, userConsentRequestContext); - - OAuth2Authorization authorization = OAuth2Authorization.from(userConsentRequestContext.getAuthorization()) - .token(authorizationCode) - .attributes(attrs -> { - attrs.remove(OAuth2ParameterNames.STATE); - attrs.put(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME, authorizedScopes); - }) - .build(); - this.authorizationService.save(authorization); - - sendAuthorizationResponse(request, response, userConsentRequestContext.resolveRedirectUri(), - authorizationCode, userConsentRequestContext.getAuthorizationRequest().getState()); - } - - private void saveAuthorizationConsent(OAuth2AuthorizationConsent currentAuthorizationConsent, UserConsentRequestContext userConsentRequestContext) { - if (CollectionUtils.isEmpty(userConsentRequestContext.getScopes())) { - return; - } - - OAuth2AuthorizationConsent.Builder authorizationConsentBuilder; - if (currentAuthorizationConsent == null) { - authorizationConsentBuilder = OAuth2AuthorizationConsent.withId( - userConsentRequestContext.getAuthorization().getRegisteredClientId(), - userConsentRequestContext.getAuthorization().getPrincipalName()); - } else { - authorizationConsentBuilder = OAuth2AuthorizationConsent.from(currentAuthorizationConsent); - } - - for (String authorizedScope : userConsentRequestContext.getScopes()) { - authorizationConsentBuilder.scope(authorizedScope); - } - OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build(); - this.authorizationConsentService.save(authorizationConsent); - } - - private void validateAuthorizationRequest(OAuth2AuthorizationRequestContext authorizationRequestContext) { - // --------------- - // Validate the request to ensure all required parameters are present and valid - // --------------- - - // client_id (REQUIRED) - if (!StringUtils.hasText(authorizationRequestContext.getClientId()) || - authorizationRequestContext.getParameters().get(OAuth2ParameterNames.CLIENT_ID).size() != 1) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID)); - return; - } - RegisteredClient registeredClient = this.registeredClientRepository.findByClientId( - authorizationRequestContext.getClientId()); - if (registeredClient == null) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID)); - return; - } else if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE)) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID)); - return; - } - authorizationRequestContext.setRegisteredClient(registeredClient); - - // redirect_uri (OPTIONAL) - if (StringUtils.hasText(authorizationRequestContext.getRedirectUri())) { - if (!isValidRedirectUri(authorizationRequestContext.getRedirectUri(), registeredClient) || - authorizationRequestContext.getParameters().get(OAuth2ParameterNames.REDIRECT_URI).size() != 1) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI)); - return; - } - } else if (authorizationRequestContext.isAuthenticationRequest() || // redirect_uri is REQUIRED for OpenID Connect - registeredClient.getRedirectUris().size() != 1) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI)); - return; - } - authorizationRequestContext.setRedirectOnError(true); - - // response_type (REQUIRED) - if (!StringUtils.hasText(authorizationRequestContext.getResponseType()) || - authorizationRequestContext.getParameters().get(OAuth2ParameterNames.RESPONSE_TYPE).size() != 1) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.RESPONSE_TYPE)); - return; - } else if (!authorizationRequestContext.getResponseType().equals(OAuth2AuthorizationResponseType.CODE.getValue())) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.UNSUPPORTED_RESPONSE_TYPE, OAuth2ParameterNames.RESPONSE_TYPE)); - return; - } - - // scope (OPTIONAL) - Set requestedScopes = authorizationRequestContext.getScopes(); - Set allowedScopes = registeredClient.getScopes(); - if (!requestedScopes.isEmpty() && !allowedScopes.containsAll(requestedScopes)) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE)); - return; - } - - // code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE) - String codeChallenge = authorizationRequestContext.getParameters().getFirst(PkceParameterNames.CODE_CHALLENGE); - if (StringUtils.hasText(codeChallenge)) { - if (authorizationRequestContext.getParameters().get(PkceParameterNames.CODE_CHALLENGE).size() != 1) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI)); - return; - } - - String codeChallengeMethod = authorizationRequestContext.getParameters().getFirst(PkceParameterNames.CODE_CHALLENGE_METHOD); - if (StringUtils.hasText(codeChallengeMethod)) { - if (authorizationRequestContext.getParameters().get(PkceParameterNames.CODE_CHALLENGE_METHOD).size() != 1 || - (!"S256".equals(codeChallengeMethod) && !"plain".equals(codeChallengeMethod))) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI)); - return; - } - } - } else if (registeredClient.getClientSettings().requireProofKey()) { - authorizationRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI)); - return; - } - } - - private void validateUserConsentRequest(UserConsentRequestContext userConsentRequestContext) { - // --------------- - // Validate the request to ensure all required parameters are present and valid - // --------------- - - // state (REQUIRED) - if (!StringUtils.hasText(userConsentRequestContext.getState()) || - userConsentRequestContext.getParameters().get(OAuth2ParameterNames.STATE).size() != 1) { - userConsentRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE)); - return; - } - OAuth2Authorization authorization = this.authorizationService.findByToken( - userConsentRequestContext.getState(), STATE_TOKEN_TYPE); - if (authorization == null) { - userConsentRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE)); - return; - } - userConsentRequestContext.setAuthorization(authorization); - - // The 'in-flight' authorization must be associated to the current principal - Authentication principal = SecurityContextHolder.getContext().getAuthentication(); - if (!isPrincipalAuthenticated(principal) || !principal.getName().equals(authorization.getPrincipalName())) { - userConsentRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE)); - return; - } - - // client_id (REQUIRED) - if (!StringUtils.hasText(userConsentRequestContext.getClientId()) || - userConsentRequestContext.getParameters().get(OAuth2ParameterNames.CLIENT_ID).size() != 1) { - userConsentRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID)); - return; - } - RegisteredClient registeredClient = this.registeredClientRepository.findByClientId( - userConsentRequestContext.getClientId()); - if (registeredClient == null || !registeredClient.getId().equals(authorization.getRegisteredClientId())) { - userConsentRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID)); - return; - } - userConsentRequestContext.setRegisteredClient(registeredClient); - userConsentRequestContext.setRedirectOnError(true); - - // scope (OPTIONAL) - Set requestedScopes = userConsentRequestContext.getAuthorizationRequest().getScopes(); - Set authorizedScopes = userConsentRequestContext.getScopes(); - if (!authorizedScopes.isEmpty() && !requestedScopes.containsAll(authorizedScopes)) { - userConsentRequestContext.setError( - createError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE)); - return; + private String resolveConsentUri(HttpServletRequest request) { + if (UrlUtils.isAbsoluteUrl(this.userConsentUri)) { + return this.userConsentUri; } + RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder(); + urlBuilder.setScheme(request.getScheme()); + urlBuilder.setServerName(request.getServerName()); + urlBuilder.setPort(request.getServerPort()); + urlBuilder.setContextPath(request.getContextPath()); + urlBuilder.setPathInfo(this.userConsentUri); + return urlBuilder.getUrl(); } private void sendAuthorizationResponse(HttpServletRequest request, HttpServletResponse response, - String redirectUri, OAuth2AuthorizationCode authorizationCode, String state) throws IOException { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication) throws IOException { UriComponentsBuilder uriBuilder = UriComponentsBuilder - .fromUriString(redirectUri) - .queryParam(OAuth2ParameterNames.CODE, authorizationCode.getTokenValue()); - if (StringUtils.hasText(state)) { - uriBuilder.queryParam(OAuth2ParameterNames.STATE, state); + .fromUriString(authorizationCodeRequestAuthentication.getRedirectUri()) + .queryParam(OAuth2ParameterNames.CODE, authorizationCodeRequestAuthentication.getAuthorizationCode().getTokenValue()); + if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) { + uriBuilder.queryParam(OAuth2ParameterNames.STATE, authorizationCodeRequestAuthentication.getState()); } this.redirectStrategy.sendRedirect(request, response, uriBuilder.toUriString()); } private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response, - String redirectUri, OAuth2Error error, String state) throws IOException { + AuthenticationException exception) throws IOException { + + OAuth2AuthorizationCodeRequestAuthenticationException authorizationCodeRequestAuthenticationException = + (OAuth2AuthorizationCodeRequestAuthenticationException) exception; + OAuth2Error error = authorizationCodeRequestAuthenticationException.getError(); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = + authorizationCodeRequestAuthenticationException.getAuthorizationCodeRequestAuthentication(); + + if (authorizationCodeRequestAuthentication == null || + !StringUtils.hasText(authorizationCodeRequestAuthentication.getRedirectUri())) { + // TODO Send default html error response + response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString()); + return; + } UriComponentsBuilder uriBuilder = UriComponentsBuilder - .fromUriString(redirectUri) + .fromUriString(authorizationCodeRequestAuthentication.getRedirectUri()) .queryParam(OAuth2ParameterNames.ERROR, error.getErrorCode()); if (StringUtils.hasText(error.getDescription())) { uriBuilder.queryParam(OAuth2ParameterNames.ERROR_DESCRIPTION, error.getDescription()); @@ -579,286 +288,170 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { if (StringUtils.hasText(error.getUri())) { uriBuilder.queryParam(OAuth2ParameterNames.ERROR_URI, error.getUri()); } - if (StringUtils.hasText(state)) { - uriBuilder.queryParam(OAuth2ParameterNames.STATE, state); + if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) { + uriBuilder.queryParam(OAuth2ParameterNames.STATE, authorizationCodeRequestAuthentication.getState()); } this.redirectStrategy.sendRedirect(request, response, uriBuilder.toUriString()); } - private void sendErrorResponse(HttpServletResponse response, OAuth2Error error) throws IOException { - // TODO Send default html error response - response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString()); - } + private static class OAuth2AuthorizationCodeRequestAuthenticationConverter implements AuthenticationConverter { + private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken( + "anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + private static final String PKCE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1"; + private final RequestMatcher oidcAuthenticationRequestMatcher; - private static OAuth2Error createError(String errorCode, String parameterName) { - return createError(errorCode, parameterName, "https://tools.ietf.org/html/rfc6749#section-4.1.2.1"); - } + private OAuth2AuthorizationCodeRequestAuthenticationConverter() { + RequestMatcher postMethodMatcher = request -> "POST".equals(request.getMethod()); + RequestMatcher responseTypeParameterMatcher = request -> + request.getParameter(OAuth2ParameterNames.RESPONSE_TYPE) != null; + RequestMatcher openidScopeMatcher = request -> { + String scope = request.getParameter(OAuth2ParameterNames.SCOPE); + return StringUtils.hasText(scope) && scope.contains(OidcScopes.OPENID); + }; + this.oidcAuthenticationRequestMatcher = new AndRequestMatcher( + postMethodMatcher, responseTypeParameterMatcher, openidScopeMatcher); + } - private static OAuth2Error createError(String errorCode, String parameterName, String errorUri) { - return new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, errorUri); - } + @Override + public Authentication convert(HttpServletRequest request) { + MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); - private static boolean isPrincipalAuthenticated(Authentication principal) { - return principal != null && - !AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) && - principal.isAuthenticated(); - } + boolean authorizationRequest = false; + if ("GET".equals(request.getMethod()) || this.oidcAuthenticationRequestMatcher.matches(request)) { + authorizationRequest = true; - private static boolean isValidRedirectUri(String requestedRedirectUri, RegisteredClient registeredClient) { - UriComponents requestedRedirect; - try { - requestedRedirect = UriComponentsBuilder.fromUriString(requestedRedirectUri).build(); - if (requestedRedirect.getFragment() != null) { - return false; + // response_type (REQUIRED) + String responseType = request.getParameter(OAuth2ParameterNames.RESPONSE_TYPE); + if (!StringUtils.hasText(responseType) || + parameters.get(OAuth2ParameterNames.RESPONSE_TYPE).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.RESPONSE_TYPE); + } else if (!responseType.equals(OAuth2AuthorizationResponseType.CODE.getValue())) { + throwError(OAuth2ErrorCodes.UNSUPPORTED_RESPONSE_TYPE, OAuth2ParameterNames.RESPONSE_TYPE); + } } - } catch (Exception ex) { - return false; - } - String requestedRedirectHost = requestedRedirect.getHost(); - if (requestedRedirectHost == null || requestedRedirectHost.equals("localhost")) { - // As per https://tools.ietf.org/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. - return false; - } - if (!LOOPBACK_ADDRESS_PATTERN.matcher(requestedRedirectHost).matches()) { - // As per https://tools.ietf.org/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. - return registeredClient.getRedirectUris().contains(requestedRedirectUri); - } + String authorizationUri = request.getRequestURL().toString(); - // As per https://tools.ietf.org/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. - for (String registeredRedirectUri : registeredClient.getRedirectUris()) { - UriComponentsBuilder registeredRedirect = UriComponentsBuilder.fromUriString(registeredRedirectUri); - registeredRedirect.port(requestedRedirect.getPort()); - if (registeredRedirect.build().toString().equals(requestedRedirect.toString())) { - return true; + // client_id (REQUIRED) + String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID); + if (!StringUtils.hasText(clientId) || + parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID); } - } - return false; - } - private static class OAuth2AuthorizationRequestContext extends AbstractRequestContext { - private final String responseType; - private final String redirectUri; + Authentication principal = SecurityContextHolder.getContext().getAuthentication(); + if (principal == null) { + principal = ANONYMOUS_AUTHENTICATION; + } - private OAuth2AuthorizationRequestContext( - String authorizationUri, MultiValueMap parameters) { - super(authorizationUri, parameters, - parameters.getFirst(OAuth2ParameterNames.CLIENT_ID), - parameters.getFirst(OAuth2ParameterNames.STATE), - extractScopes(parameters)); - this.responseType = parameters.getFirst(OAuth2ParameterNames.RESPONSE_TYPE); - this.redirectUri = parameters.getFirst(OAuth2ParameterNames.REDIRECT_URI); - } + // redirect_uri (OPTIONAL) + String redirectUri = parameters.getFirst(OAuth2ParameterNames.REDIRECT_URI); + if (StringUtils.hasText(redirectUri) && + parameters.get(OAuth2ParameterNames.REDIRECT_URI).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI); + } - private static Set extractScopes(MultiValueMap parameters) { - String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE); - return StringUtils.hasText(scope) ? - new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " "))) : - Collections.emptySet(); - } + // scope (OPTIONAL) + Set scopes = null; + if (authorizationRequest) { + String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE); + if (StringUtils.hasText(scope) && + parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.SCOPE); + } + if (StringUtils.hasText(scope)) { + scopes = new HashSet<>( + Arrays.asList(StringUtils.delimitedListToStringArray(scope, " "))); + } + } else { + // Consent request + if (parameters.containsKey(OAuth2ParameterNames.SCOPE)) { + scopes = new HashSet<>(parameters.get(OAuth2ParameterNames.SCOPE)); + } + } - private String getResponseType() { - return this.responseType; - } + // state (RECOMMENDED) + String state = parameters.getFirst(OAuth2ParameterNames.STATE); + if (StringUtils.hasText(state) && + parameters.get(OAuth2ParameterNames.STATE).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE); + } - private String getRedirectUri() { - return this.redirectUri; - } + // code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE) + String codeChallenge = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE); + if (StringUtils.hasText(codeChallenge) && + parameters.get(PkceParameterNames.CODE_CHALLENGE).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI); + } - private boolean isAuthenticationRequest() { - return getScopes().contains(OidcScopes.OPENID); - } + // code_challenge_method (OPTIONAL for public clients) - RFC 7636 (PKCE) + String codeChallengeMethod = parameters.getFirst(PkceParameterNames.CODE_CHALLENGE_METHOD); + if (StringUtils.hasText(codeChallengeMethod) && + parameters.get(PkceParameterNames.CODE_CHALLENGE_METHOD).size() != 1) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI); + } - protected String resolveRedirectUri() { - return StringUtils.hasText(getRedirectUri()) ? - getRedirectUri() : - getRegisteredClient().getRedirectUris().iterator().next(); - } + // @formatter:off + Map additionalParameters = parameters + .entrySet() + .stream() + .filter(e -> !e.getKey().equals(OAuth2ParameterNames.RESPONSE_TYPE) && + !e.getKey().equals(OAuth2ParameterNames.CLIENT_ID) && + !e.getKey().equals(OAuth2ParameterNames.REDIRECT_URI) && + !e.getKey().equals(OAuth2ParameterNames.SCOPE) && + !e.getKey().equals(OAuth2ParameterNames.STATE)) + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0))); + // @formatter:on - private OAuth2AuthorizationRequest buildAuthorizationRequest() { - return OAuth2AuthorizationRequest.authorizationCode() - .authorizationUri(getAuthorizationUri()) - .clientId(getClientId()) - .redirectUri(getRedirectUri()) - .scopes(getScopes()) - .state(getState()) - .additionalParameters(additionalParameters -> - getParameters().entrySet().stream() - .filter(e -> !e.getKey().equals(OAuth2ParameterNames.RESPONSE_TYPE) && - !e.getKey().equals(OAuth2ParameterNames.CLIENT_ID) && - !e.getKey().equals(OAuth2ParameterNames.REDIRECT_URI) && - !e.getKey().equals(OAuth2ParameterNames.SCOPE) && - !e.getKey().equals(OAuth2ParameterNames.STATE)) - .forEach(e -> additionalParameters.put(e.getKey(), e.getValue().get(0)))) + return OAuth2AuthorizationCodeRequestAuthenticationToken.with(clientId, principal) + .authorizationUri(authorizationUri) + .redirectUri(redirectUri) + .scopes(scopes) + .state(state) + .additionalParameters(additionalParameters) + .consent(!authorizationRequest) .build(); } + + private static void throwError(String errorCode, String parameterName) { + throwError(errorCode, parameterName, "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"); + } + + private static void throwError(String errorCode, String parameterName, String errorUri) { + OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, errorUri); + throw new OAuth2AuthorizationCodeRequestAuthenticationException(error, null); + } + } - private static class UserConsentRequestContext extends AbstractRequestContext { - private OAuth2Authorization authorization; - - private UserConsentRequestContext( - String authorizationUri, MultiValueMap parameters) { - super(authorizationUri, parameters, - parameters.getFirst(OAuth2ParameterNames.CLIENT_ID), - parameters.getFirst(OAuth2ParameterNames.STATE), - extractScopes(parameters)); - } - - private static Set extractScopes(MultiValueMap parameters) { - List scope = parameters.get(OAuth2ParameterNames.SCOPE); - return !CollectionUtils.isEmpty(scope) ? new HashSet<>(scope) : new HashSet<>(); - } - - private OAuth2Authorization getAuthorization() { - return this.authorization; - } - - private void setAuthorization(OAuth2Authorization authorization) { - this.authorization = authorization; - } - - protected String resolveRedirectUri() { - OAuth2AuthorizationRequest authorizationRequest = getAuthorizationRequest(); - return StringUtils.hasText(authorizationRequest.getRedirectUri()) ? - authorizationRequest.getRedirectUri() : - getRegisteredClient().getRedirectUris().iterator().next(); - } - - private OAuth2AuthorizationRequest getAuthorizationRequest() { - return getAuthorization().getAttribute(OAuth2AuthorizationRequest.class.getName()); - } - } - - private abstract static class AbstractRequestContext { - private final String authorizationUri; - private final MultiValueMap parameters; - private final String clientId; - private final String state; - private final Set scopes; - private RegisteredClient registeredClient; - private OAuth2Error error; - private boolean redirectOnError; - - protected AbstractRequestContext(String authorizationUri, MultiValueMap parameters, - String clientId, String state, Set scopes) { - this.authorizationUri = authorizationUri; - this.parameters = parameters; - this.clientId = clientId; - this.state = state; - this.scopes = scopes; - } - - protected String getAuthorizationUri() { - return this.authorizationUri; - } - - protected MultiValueMap getParameters() { - return this.parameters; - } - - protected String getClientId() { - return this.clientId; - } - - protected String getState() { - return this.state; - } - - protected Set getScopes() { - return this.scopes; - } - - protected RegisteredClient getRegisteredClient() { - return this.registeredClient; - } - - protected void setRegisteredClient(RegisteredClient registeredClient) { - this.registeredClient = registeredClient; - } - - protected OAuth2Error getError() { - return this.error; - } - - protected void setError(OAuth2Error error) { - this.error = error; - } - - protected boolean hasError() { - return getError() != null; - } - - protected boolean isRedirectOnError() { - return this.redirectOnError; - } - - protected void setRedirectOnError(boolean redirectOnError) { - this.redirectOnError = redirectOnError; - } - - protected abstract String resolveRedirectUri(); - } - + /** + * For internal use only. + */ private static class UserConsentPage { private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8); - private static final String CONSENT_ACTION_PARAMETER_NAME = "consent_action"; - private static final String CONSENT_ACTION_APPROVE = "approve"; - private static final String CONSENT_ACTION_CANCEL = "cancel"; private static void displayConsent(HttpServletRequest request, HttpServletResponse response, - RegisteredClient registeredClient, OAuth2Authorization authorization, - OAuth2AuthorizationConsent currentAuthorizationConsent) throws IOException { + String clientId, Authentication principal, Set requestedScopes, Set authorizedScopes, String state) + throws IOException { - String consentPage = generateConsentPage(request, registeredClient, authorization, currentAuthorizationConsent); + String consentPage = generateConsentPage(request, clientId, principal, requestedScopes, authorizedScopes, state); response.setContentType(TEXT_HTML_UTF8.toString()); response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length); response.getWriter().write(consentPage); } - private static boolean isConsentApproved(HttpServletRequest request) { - return CONSENT_ACTION_APPROVE.equalsIgnoreCase(request.getParameter(CONSENT_ACTION_PARAMETER_NAME)); - } - - private static boolean isConsentCancelled(HttpServletRequest request) { - return CONSENT_ACTION_CANCEL.equalsIgnoreCase(request.getParameter(CONSENT_ACTION_PARAMETER_NAME)); - } - private static String generateConsentPage(HttpServletRequest request, - RegisteredClient registeredClient, OAuth2Authorization authorization, - OAuth2AuthorizationConsent currentAuthorizationConsent) { - - Set currentAuthorizedScopes; - if (currentAuthorizationConsent != null) { - currentAuthorizedScopes = currentAuthorizationConsent.getScopes(); - } else { - currentAuthorizedScopes = Collections.emptySet(); - } - - OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( - OAuth2AuthorizationRequest.class.getName()); - + String clientId, Authentication principal, Set requestedScopes, Set authorizedScopes, String state) { Set scopesToAuthorize = new HashSet<>(); Set scopesPreviouslyAuthorized = new HashSet<>(); - for (String scope : authorizationRequest.getScopes()) { - if (currentAuthorizedScopes.contains(scope)) { + for (String scope : requestedScopes) { + if (authorizedScopes.contains(scope)) { scopesPreviouslyAuthorized.add(scope); } else if (!scope.equals(OidcScopes.OPENID)) { // openid scope does not require consent scopesToAuthorize.add(scope); } } - String state = authorization.getAttribute(OAuth2ParameterNames.STATE); - StringBuilder builder = new StringBuilder(); builder.append(""); @@ -876,7 +469,7 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { builder.append(" "); builder.append("
"); builder.append("
"); - builder.append("

" + registeredClient.getClientId() + " wants to access your account " + authorization.getPrincipalName() + "

"); + builder.append("

" + clientId + " wants to access your account " + principal.getName() + "

"); builder.append("
"); builder.append("
"); builder.append("
"); @@ -887,7 +480,7 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { builder.append("
"); builder.append("
"); builder.append("
"); - builder.append(" "); + builder.append(" "); builder.append(" "); for (String scope : scopesToAuthorize) { @@ -908,10 +501,10 @@ public class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter { } builder.append("
"); - builder.append(" "); + builder.append(" "); builder.append("
"); builder.append("
"); - builder.append(" "); + builder.append(" "); builder.append("
"); builder.append("
"); builder.append("
"); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java index 0562338a..05569da1 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationCodeGrantTests.java @@ -410,7 +410,6 @@ public class OAuth2AuthorizationCodeGrantTests { .param(OAuth2ParameterNames.SCOPE, "message.read") .param(OAuth2ParameterNames.SCOPE, "message.write") .param(OAuth2ParameterNames.STATE, "state") - .param("consent_action", "approve") .with(user("user"))) .andExpect(status().is3xxRedirection()) .andReturn(); @@ -455,7 +454,7 @@ public class OAuth2AuthorizationCodeGrantTests { .andExpect(status().is3xxRedirection()) .andReturn(); String redirectedUrl = mvcResult.getResponse().getRedirectedUrl(); - assertThat(redirectedUrl).matches("/oauth2/consent\\?scope=.+&client_id=.+&state=.+"); + assertThat(redirectedUrl).matches("http://localhost/oauth2/consent\\?scope=.+&client_id=.+&state=.+"); String locationHeader = URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8.name()); UriComponents uriComponents = UriComponentsBuilder.fromUriString(locationHeader).build(); 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 new file mode 100644 index 00000000..f91a55a9 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java @@ -0,0 +1,861 @@ +/* + * 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.authentication; + +import java.security.Principal; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +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.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; +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.OAuth2AuthorizationCodeRequestAuthenticationException; +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 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.when; + +/** + * Tests for {@link OAuth2AuthorizationCodeRequestAuthenticationProvider}. + * + * @author Joe Grandja + */ +public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests { + private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); + private RegisteredClientRepository registeredClientRepository; + private OAuth2AuthorizationService authorizationService; + private OAuth2AuthorizationConsentService authorizationConsentService; + private OAuth2AuthorizationCodeRequestAuthenticationProvider authenticationProvider; + private TestingAuthenticationToken principal; + + @Before + public void setUp() { + this.registeredClientRepository = mock(RegisteredClientRepository.class); + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class); + this.authenticationProvider = new OAuth2AuthorizationCodeRequestAuthenticationProvider( + this.registeredClientRepository, this.authorizationService, this.authorizationConsentService); + this.principal = new TestingAuthenticationToken("principalName", "password"); + this.principal.setAuthenticated(true); + } + + @Test + public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2AuthorizationCodeRequestAuthenticationProvider( + null, this.authorizationService, this.authorizationConsentService)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("registeredClientRepository cannot be null"); + } + + @Test + public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2AuthorizationCodeRequestAuthenticationProvider( + this.registeredClientRepository, null, this.authorizationConsentService)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authorizationService cannot be null"); + } + + @Test + public void constructorWhenAuthorizationConsentServiceNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2AuthorizationCodeRequestAuthenticationProvider( + this.registeredClientRepository, this.authorizationService, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authorizationConsentService cannot be null"); + } + + @Test + public void supportsWhenTypeOAuth2AuthorizationCodeRequestAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(OAuth2AuthorizationCodeRequestAuthenticationToken.class)).isTrue(); + } + + @Test + public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, null) + ); + } + + // gh-243 + @Test + public void authenticateWhenInvalidRedirectUriHostThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .redirectUri("https:///invalid") + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null) + ); + } + + // gh-243 + @Test + public void authenticateWhenInvalidRedirectUriFragmentThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .redirectUri("https://example.com#fragment") + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null) + ); + } + + // gh-243 + @Test + public void authenticateWhenRedirectUriLocalhostThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .redirectUri("https://localhost:5000") + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null) + ); + } + + @Test + public void authenticateWhenUnregisteredRedirectUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .redirectUri("https://invalid-example.com") + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null) + ); + } + + // gh-243 + @Test + public void authenticateWhenRedirectUriIPv4LoopbackAndDifferentPortThenReturnAuthorizationCode() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .redirectUri("https://127.0.0.1:8080") + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .redirectUri("https://127.0.0.1:5000") + .build(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication, authenticationResult); + } + + // gh-243 + @Test + public void authenticateWhenRedirectUriIPv6LoopbackAndDifferentPortThenReturnAuthorizationCode() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .redirectUri("https://[::1]:8080") + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .redirectUri("https://[::1]:5000") + .build(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication, authenticationResult); + } + + @Test + public void authenticateWhenMissingRedirectUriAndMultipleRegisteredThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUri("https://example2.com").build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .redirectUri(null) + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null) + ); + } + + @Test + public void authenticateWhenAuthenticationRequestMissingRedirectUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + // redirect_uri is REQUIRED for OpenID Connect requests + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scope(OidcScopes.OPENID) + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .redirectUri(null) + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null) + ); + } + + @Test + public void authenticateWhenClientNotAuthorizedToRequestCodeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantTypes(Set::clear) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID, authentication.getRedirectUri()) + ); + } + + @Test + public void authenticateWhenInvalidScopeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .scopes(Collections.singleton("invalid-scope")) + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, authentication.getRedirectUri()) + ); + } + + @Test + public void authenticateWhenPkceRequiredAndMissingCodeChallengeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .clientSettings(clientSettings -> clientSettings.requireProofKey(true)) + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, authentication.getRedirectUri()) + ); + } + + @Test + public void authenticateWhenPkceUnsupportedCodeChallengeMethodThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + Map additionalParameters = new HashMap<>(); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "unsupported"); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .additionalParameters(additionalParameters) + .build(); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, authentication.getRedirectUri()) + ); + } + + @Test + public void authenticateWhenPrincipalNotAuthenticatedThenReturnAuthorizationCodeRequest() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + this.principal.setAuthenticated(false); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + assertThat(authenticationResult).isSameAs(authentication); + assertThat(authenticationResult.isAuthenticated()).isFalse(); + } + + @Test + public void authenticateWhenRequireAuthorizationConsentThenReturnAuthorizationConsent() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).save(authorizationCaptor.capture()); + OAuth2Authorization authorization = authorizationCaptor.getValue(); + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + assertThat(authorizationRequest.getGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); + assertThat(authorizationRequest.getResponseType()).isEqualTo(OAuth2AuthorizationResponseType.CODE); + assertThat(authorizationRequest.getAuthorizationUri()).isEqualTo(authentication.getAuthorizationUri()); + assertThat(authorizationRequest.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authorizationRequest.getRedirectUri()).isEqualTo(authentication.getRedirectUri()); + assertThat(authorizationRequest.getScopes()).isEqualTo(authentication.getScopes()); + assertThat(authorizationRequest.getState()).isEqualTo(authentication.getState()); + assertThat(authorizationRequest.getAdditionalParameters()).isEqualTo(authentication.getAdditionalParameters()); + + assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId()); + assertThat(authorization.getPrincipalName()).isEqualTo(this.principal.getName()); + assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); + assertThat(authorization.getAttribute(Principal.class.getName())).isEqualTo(this.principal); + String state = authorization.getAttribute(OAuth2ParameterNames.STATE); + assertThat(state).isNotNull(); + assertThat(state).isNotEqualTo(authentication.getState()); + + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPrincipal()).isEqualTo(this.principal); + assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(authorizationRequest.getAuthorizationUri()); + assertThat(authenticationResult.getScopes()).isEmpty(); + assertThat(authenticationResult.getState()).isEqualTo(state); + assertThat(authenticationResult.isConsentRequired()).isTrue(); + assertThat(authenticationResult.getAuthorizationCode()).isNull(); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + } + + @Test + public void authenticateWhenRequireAuthorizationConsentAndOnlyOpenidScopeRequestedThenAuthorizationConsentNotRequired() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .scopes(scopes -> { + scopes.clear(); + scopes.add(OidcScopes.OPENID); + }) + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication, authenticationResult); + } + + @Test + public void authenticateWhenRequireAuthorizationConsentAndAllPreviouslyApprovedThenAuthorizationConsentNotRequired() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + OAuth2AuthorizationConsent.Builder builder = + OAuth2AuthorizationConsent.withId(registeredClient.getId(), this.principal.getName()); + registeredClient.getScopes().forEach(builder::scope); + OAuth2AuthorizationConsent previousAuthorizationConsent = builder.build(); + when(this.authorizationConsentService.findById(eq(registeredClient.getId()), eq(this.principal.getName()))) + .thenReturn(previousAuthorizationConsent); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication, authenticationResult); + } + + @Test + public void authenticateWhenAuthorizationCodeRequestValidThenReturnAuthorizationCode() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + Map additionalParameters = new HashMap<>(); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .additionalParameters(additionalParameters) + .build(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication, authenticationResult); + } + + private void assertAuthorizationCodeRequestWithAuthorizationCodeResult( + RegisteredClient registeredClient, + OAuth2AuthorizationCodeRequestAuthenticationToken authentication, + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult) { + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).save(authorizationCaptor.capture()); + OAuth2Authorization authorization = authorizationCaptor.getValue(); + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + assertThat(authorizationRequest.getGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); + assertThat(authorizationRequest.getResponseType()).isEqualTo(OAuth2AuthorizationResponseType.CODE); + assertThat(authorizationRequest.getAuthorizationUri()).isEqualTo(authentication.getAuthorizationUri()); + assertThat(authorizationRequest.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authorizationRequest.getRedirectUri()).isEqualTo(authentication.getRedirectUri()); + assertThat(authorizationRequest.getScopes()).isEqualTo(authentication.getScopes()); + assertThat(authorizationRequest.getState()).isEqualTo(authentication.getState()); + assertThat(authorizationRequest.getAdditionalParameters()).isEqualTo(authentication.getAdditionalParameters()); + + assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId()); + assertThat(authorization.getPrincipalName()).isEqualTo(this.principal.getName()); + assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); + assertThat(authorization.getAttribute(Principal.class.getName())).isEqualTo(this.principal); + + OAuth2Authorization.Token authorizationCode = authorization.getToken(OAuth2AuthorizationCode.class); + Set authorizedScopes = authorization.getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME); + + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPrincipal()).isEqualTo(this.principal); + assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(authorizationRequest.getAuthorizationUri()); + assertThat(authenticationResult.getRedirectUri()).isEqualTo(authorizationRequest.getRedirectUri()); + assertThat(authenticationResult.getScopes()).isEqualTo(authorizedScopes); + assertThat(authenticationResult.getState()).isEqualTo(authorizationRequest.getState()); + assertThat(authenticationResult.getAuthorizationCode()).isEqualTo(authorizationCode.getToken()); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + } + + @Test + public void authenticateWhenConsentRequestInvalidStateThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(registeredClient, this.principal) + .build(); + when(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE))) + .thenReturn(null); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE, null) + ); + } + + @Test + public void authenticateWhenConsentRequestPrincipalNotAuthenticatedThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.principal.getName()) + .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(registeredClient, this.principal) + .build(); + when(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + this.principal.setAuthenticated(false); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE, null) + ); + } + + @Test + public void authenticateWhenConsentRequestInvalidPrincipalThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.principal.getName().concat("-other")) + .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(registeredClient, this.principal) + .build(); + when(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE, null) + ); + } + + @Test + public void authenticateWhenConsentRequestInvalidClientIdThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.principal.getName()) + .build(); + when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + RegisteredClient otherRegisteredClient = TestRegisteredClients.registeredClient2() + .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(otherRegisteredClient, this.principal) + .state("state") + .build(); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, null) + ); + } + + @Test + public void authenticateWhenConsentRequestDoesNotMatchClientThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + RegisteredClient otherRegisteredClient = TestRegisteredClients.registeredClient2() + .build(); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(otherRegisteredClient) + .principalName(this.principal.getName()) + .build(); + when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(registeredClient, this.principal) + .state("state") + .build(); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, null) + ); + } + + @Test + public void authenticateWhenConsentRequestScopeNotRequestedThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.principal.getName()) + .build(); + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + Set authorizedScopes = new HashSet<>(authorizationRequest.getScopes()); + authorizedScopes.add("scope-not-requested"); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(registeredClient, this.principal) + .scopes(authorizedScopes) + .build(); + when(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, authorizationRequest.getRedirectUri()) + ); + } + + @Test + public void authenticateWhenConsentRequestNotApprovedThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.principal.getName()) + .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(registeredClient, this.principal) + .scopes(new HashSet<>()) // No scopes approved + .build(); + when(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies(ex -> + assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.ACCESS_DENIED, OAuth2ParameterNames.CLIENT_ID, authorizationRequest.getRedirectUri()) + ); + + verify(this.authorizationService).remove(eq(authorization)); + } + + @Test + public void authenticateWhenConsentRequestApproveAllThenReturnAuthorizationCode() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.principal.getName()) + .build(); + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + Set authorizedScopes = authorizationRequest.getScopes(); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(registeredClient, this.principal) + .scopes(authorizedScopes) // Approve all scopes + .build(); + when(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + ArgumentCaptor authorizationConsentCaptor = ArgumentCaptor.forClass(OAuth2AuthorizationConsent.class); + verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture()); + OAuth2AuthorizationConsent authorizationConsent = authorizationConsentCaptor.getValue(); + + assertThat(authorizationConsent.getRegisteredClientId()).isEqualTo(authorization.getRegisteredClientId()); + assertThat(authorizationConsent.getPrincipalName()).isEqualTo(authorization.getPrincipalName()); + assertThat(authorizationConsent.getAuthorities()).hasSize(authorizedScopes.size()); + assertThat(authorizationConsent.getScopes()).containsExactlyInAnyOrderElementsOf(authorizedScopes); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).save(authorizationCaptor.capture()); + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + + assertThat(updatedAuthorization.getRegisteredClientId()).isEqualTo(authorization.getRegisteredClientId()); + assertThat(updatedAuthorization.getPrincipalName()).isEqualTo(authorization.getPrincipalName()); + assertThat(updatedAuthorization.getAuthorizationGrantType()).isEqualTo(authorization.getAuthorizationGrantType()); + assertThat(updatedAuthorization.getAttribute(Principal.class.getName())) + .isEqualTo(authorization.getAttribute(Principal.class.getName())); + assertThat(updatedAuthorization.getAttribute(OAuth2AuthorizationRequest.class.getName())) + .isEqualTo(authorizationRequest); + OAuth2Authorization.Token authorizationCode = updatedAuthorization.getToken(OAuth2AuthorizationCode.class); + assertThat(authorizationCode).isNotNull(); + assertThat(updatedAuthorization.getAttribute(OAuth2ParameterNames.STATE)).isNull(); + assertThat(updatedAuthorization.>getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME)) + .isEqualTo(authorizedScopes); + + assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getPrincipal()).isEqualTo(this.principal); + assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(authorizationRequest.getAuthorizationUri()); + assertThat(authenticationResult.getRedirectUri()).isEqualTo(authorizationRequest.getRedirectUri()); + assertThat(authenticationResult.getScopes()).isEqualTo(authorizedScopes); + assertThat(authenticationResult.getState()).isEqualTo(authorizationRequest.getState()); + assertThat(authenticationResult.getAuthorizationCode()).isEqualTo(authorizationCode.getToken()); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + } + + @Test + public void authenticateWhenConsentRequestWithPreviouslyApprovedThenAuthorizationConsentUpdated() { + String previouslyApprovedScope = "message.read"; + String requestedScope = "message.write"; + String otherPreviouslyApprovedScope = "other.scope"; + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add(previouslyApprovedScope); + scopes.add(requestedScope); + }) + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.principal.getName()) + .build(); + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + Set requestedScopes = authorizationRequest.getScopes(); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(registeredClient, this.principal) + .scopes(requestedScopes) + .build(); + when(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + OAuth2AuthorizationConsent previousAuthorizationConsent = + OAuth2AuthorizationConsent.withId(authorization.getRegisteredClientId(), authorization.getPrincipalName()) + .scope(previouslyApprovedScope) + .scope(otherPreviouslyApprovedScope) + .build(); + when(this.authorizationConsentService.findById(eq(authorization.getRegisteredClientId()), eq(authorization.getPrincipalName()))) + .thenReturn(previousAuthorizationConsent); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + ArgumentCaptor authorizationConsentCaptor = ArgumentCaptor.forClass(OAuth2AuthorizationConsent.class); + verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture()); + OAuth2AuthorizationConsent updatedAuthorizationConsent = authorizationConsentCaptor.getValue(); + + assertThat(updatedAuthorizationConsent.getRegisteredClientId()).isEqualTo(previousAuthorizationConsent.getRegisteredClientId()); + assertThat(updatedAuthorizationConsent.getPrincipalName()).isEqualTo(previousAuthorizationConsent.getPrincipalName()); + assertThat(updatedAuthorizationConsent.getScopes()).containsExactlyInAnyOrder( + previouslyApprovedScope, otherPreviouslyApprovedScope, requestedScope); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + verify(this.authorizationService).save(authorizationCaptor.capture()); + OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); + + assertThat(updatedAuthorization.>getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME)) + .isEqualTo(requestedScopes); + + assertThat(authenticationResult.getScopes()).isEqualTo(requestedScopes); + } + + @Test + public void authenticateWhenConsentRequestApproveNoneButPreviouslyApprovedThenAuthorizationConsentNotUpdated() { + String previouslyApprovedScope = "message.read"; + String requestedScope = "message.write"; + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.add(previouslyApprovedScope); + scopes.add(requestedScope); + }) + .build(); + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) + .principalName(this.principal.getName()) + .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + authorizationConsentRequestAuthentication(registeredClient, this.principal) + .scopes(new HashSet<>()) // No scopes approved + .build(); + when(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE))) + .thenReturn(authorization); + OAuth2AuthorizationConsent previousAuthorizationConsent = + OAuth2AuthorizationConsent.withId(authorization.getRegisteredClientId(), authorization.getPrincipalName()) + .scope(previouslyApprovedScope) + .build(); + when(this.authorizationConsentService.findById(eq(authorization.getRegisteredClientId()), eq(authorization.getPrincipalName()))) + .thenReturn(previousAuthorizationConsent); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = + (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + verify(this.authorizationConsentService, never()).save(any()); + assertThat(authenticationResult.getScopes()).isEqualTo(Collections.singleton(previouslyApprovedScope)); + } + + private static void assertAuthenticationException(OAuth2AuthorizationCodeRequestAuthenticationException authenticationException, + String errorCode, String parameterName, String redirectUri) { + + OAuth2Error error = authenticationException.getError(); + assertThat(error.getErrorCode()).isEqualTo(errorCode); + assertThat(error.getDescription()).contains(parameterName); + + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = + authenticationException.getAuthorizationCodeRequestAuthentication(); + assertThat(authorizationCodeRequestAuthentication.getRedirectUri()).isEqualTo(redirectUri); + } + + private static OAuth2AuthorizationCodeRequestAuthenticationToken.Builder authorizationCodeRequestAuthentication( + RegisteredClient registeredClient, Authentication principal) { + return OAuth2AuthorizationCodeRequestAuthenticationToken.with(registeredClient.getClientId(), principal) + .authorizationUri("https://provider.com/oauth2/authorize") + .redirectUri(registeredClient.getRedirectUris().iterator().next()) + .scopes(registeredClient.getScopes()) + .state("state"); + } + + private static OAuth2AuthorizationCodeRequestAuthenticationToken.Builder authorizationConsentRequestAuthentication( + RegisteredClient registeredClient, Authentication principal) { + return OAuth2AuthorizationCodeRequestAuthenticationToken.with(registeredClient.getClientId(), principal) + .authorizationUri("https://provider.com/oauth2/authorize") + .scopes(registeredClient.getScopes()) + .state("state") + .consent(true); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationTokenTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationTokenTests.java new file mode 100644 index 00000000..d60d8407 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationTokenTests.java @@ -0,0 +1,200 @@ +/* + * 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.authentication; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.junit.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for {@link OAuth2AuthorizationCodeRequestAuthenticationToken}. + * + * @author Joe Grandja + */ +public class OAuth2AuthorizationCodeRequestAuthenticationTokenTests { + private static final String AUTHORIZATION_URI = "https://provider.com/oauth2/authorize"; + private static final String STATE = "state"; + private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build(); + private static final TestingAuthenticationToken PRINCIPAL = new TestingAuthenticationToken("principalName", "password"); + private static final OAuth2AuthorizationCode AUTHORIZATION_CODE = + new OAuth2AuthorizationCode("code", Instant.now(), Instant.now().plus(5, ChronoUnit.MINUTES)); + + @Test + public void withWhenClientIdNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> OAuth2AuthorizationCodeRequestAuthenticationToken.with(null, PRINCIPAL)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("clientId cannot be empty"); + } + + @Test + public void withWhenPrincipalNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> OAuth2AuthorizationCodeRequestAuthenticationToken.with(REGISTERED_CLIENT.getClientId(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("principal cannot be null"); + } + + @Test + public void buildWhenAuthorizationUriNotProvidedThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> + OAuth2AuthorizationCodeRequestAuthenticationToken.with(REGISTERED_CLIENT.getClientId(), PRINCIPAL) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authorizationUri cannot be empty"); + } + + @Test + public void buildWhenStateNotProvidedThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> + OAuth2AuthorizationCodeRequestAuthenticationToken.with(REGISTERED_CLIENT.getClientId(), PRINCIPAL) + .authorizationUri(AUTHORIZATION_URI) + .consent(true) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("state cannot be empty"); + } + + @Test + public void buildWhenAuthorizationCodeRequestThenValuesAreSet() { + String clientId = REGISTERED_CLIENT.getClientId(); + String redirectUri = REGISTERED_CLIENT.getRedirectUris().iterator().next(); + Set requestedScopes = REGISTERED_CLIENT.getScopes(); + Map additionalParameters = Collections.singletonMap("param1", "value1"); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + OAuth2AuthorizationCodeRequestAuthenticationToken.with(clientId, PRINCIPAL) + .authorizationUri(AUTHORIZATION_URI) + .redirectUri(redirectUri) + .scopes(requestedScopes) + .state(STATE) + .additionalParameters(additionalParameters) + .build(); + + assertThat(authentication.getPrincipal()).isEqualTo(PRINCIPAL); + assertThat(authentication.getCredentials()).isEqualTo(""); + assertThat(authentication.getAuthorities()).isEmpty(); + assertThat(authentication.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI); + assertThat(authentication.getClientId()).isEqualTo(clientId); + assertThat(authentication.getRedirectUri()).isEqualTo(redirectUri); + assertThat(authentication.getScopes()).containsExactlyInAnyOrderElementsOf(requestedScopes); + assertThat(authentication.getState()).isEqualTo(STATE); + assertThat(authentication.getAdditionalParameters()).containsExactlyInAnyOrderEntriesOf(additionalParameters); + assertThat(authentication.isConsentRequired()).isFalse(); + assertThat(authentication.isConsent()).isFalse(); + assertThat(authentication.getAuthorizationCode()).isNull(); + assertThat(authentication.isAuthenticated()).isFalse(); + } + + @Test + public void buildWhenAuthorizationConsentRequiredThenValuesAreSet() { + String clientId = REGISTERED_CLIENT.getClientId(); + Set authorizedScopes = REGISTERED_CLIENT.getScopes(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + OAuth2AuthorizationCodeRequestAuthenticationToken.with(clientId, PRINCIPAL) + .authorizationUri(AUTHORIZATION_URI) + .scopes(authorizedScopes) + .state(STATE) + .consentRequired(true) + .build(); + + assertThat(authentication.getPrincipal()).isEqualTo(PRINCIPAL); + assertThat(authentication.getCredentials()).isEqualTo(""); + assertThat(authentication.getAuthorities()).isEmpty(); + assertThat(authentication.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI); + assertThat(authentication.getClientId()).isEqualTo(clientId); + assertThat(authentication.getRedirectUri()).isNull(); + assertThat(authentication.getScopes()).containsExactlyInAnyOrderElementsOf(authorizedScopes); + assertThat(authentication.getState()).isEqualTo(STATE); + assertThat(authentication.getAdditionalParameters()).isEmpty(); + assertThat(authentication.isConsentRequired()).isTrue(); + assertThat(authentication.isConsent()).isFalse(); + assertThat(authentication.getAuthorizationCode()).isNull(); + assertThat(authentication.isAuthenticated()).isTrue(); + } + + @Test + public void buildWhenAuthorizationConsentRequestThenValuesAreSet() { + String clientId = REGISTERED_CLIENT.getClientId(); + Set authorizedScopes = REGISTERED_CLIENT.getScopes(); + Map additionalParameters = Collections.singletonMap("param1", "value1"); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + OAuth2AuthorizationCodeRequestAuthenticationToken.with(clientId, PRINCIPAL) + .authorizationUri(AUTHORIZATION_URI) + .scopes(authorizedScopes) + .state(STATE) + .additionalParameters(additionalParameters) + .consent(true) + .build(); + + assertThat(authentication.getPrincipal()).isEqualTo(PRINCIPAL); + assertThat(authentication.getCredentials()).isEqualTo(""); + assertThat(authentication.getAuthorities()).isEmpty(); + assertThat(authentication.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI); + assertThat(authentication.getClientId()).isEqualTo(clientId); + assertThat(authentication.getRedirectUri()).isNull(); + assertThat(authentication.getScopes()).containsExactlyInAnyOrderElementsOf(authorizedScopes); + assertThat(authentication.getState()).isEqualTo(STATE); + assertThat(authentication.getAdditionalParameters()).containsExactlyInAnyOrderEntriesOf(additionalParameters); + assertThat(authentication.isConsentRequired()).isFalse(); + assertThat(authentication.isConsent()).isTrue(); + assertThat(authentication.getAuthorizationCode()).isNull(); + assertThat(authentication.isAuthenticated()).isFalse(); + } + + @Test + public void buildWhenAuthorizationResponseThenValuesAreSet() { + String clientId = REGISTERED_CLIENT.getClientId(); + String redirectUri = REGISTERED_CLIENT.getRedirectUris().iterator().next(); + Set authorizedScopes = REGISTERED_CLIENT.getScopes(); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = + OAuth2AuthorizationCodeRequestAuthenticationToken.with(clientId, PRINCIPAL) + .authorizationUri(AUTHORIZATION_URI) + .redirectUri(redirectUri) + .scopes(authorizedScopes) + .state(STATE) + .authorizationCode(AUTHORIZATION_CODE) + .build(); + + assertThat(authentication.getPrincipal()).isEqualTo(PRINCIPAL); + assertThat(authentication.getCredentials()).isEqualTo(""); + assertThat(authentication.getAuthorities()).isEmpty(); + assertThat(authentication.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI); + assertThat(authentication.getClientId()).isEqualTo(clientId); + assertThat(authentication.getRedirectUri()).isEqualTo(redirectUri); + assertThat(authentication.getScopes()).containsExactlyInAnyOrderElementsOf(authorizedScopes); + assertThat(authentication.getState()).isEqualTo(STATE); + assertThat(authentication.getAdditionalParameters()).isEmpty(); + assertThat(authentication.isConsentRequired()).isFalse(); + assertThat(authentication.isConsent()).isFalse(); + assertThat(authentication.getAuthorizationCode()).isEqualTo(AUTHORIZATION_CODE); + assertThat(authentication.isAuthenticated()).isTrue(); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java index 1dd20ef7..1d3e1057 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java @@ -15,13 +15,12 @@ */ 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.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; +import java.util.HashSet; import java.util.Set; import java.util.function.Consumer; @@ -32,44 +31,33 @@ import javax.servlet.http.HttpServletResponse; 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; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -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.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType; 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.OAuth2AuthorizationCodeRequestAuthenticationException; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; 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.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; @@ -84,27 +72,23 @@ import static org.mockito.Mockito.when; * @since 0.0.1 */ public class OAuth2AuthorizationEndpointFilterTests { - private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); - private static final String DEFAULT_ERROR_URI = "https://tools.ietf.org/html/rfc6749%23section-4.1.2.1"; - private static final String PKCE_ERROR_URI = "https://tools.ietf.org/html/rfc7636%23section-4.4.1"; - private RegisteredClientRepository registeredClientRepository; - private OAuth2AuthorizationService authorizationService; - private OAuth2AuthorizationConsentService authorizationConsentService; + private AuthenticationManager authenticationManager; private OAuth2AuthorizationEndpointFilter filter; - private TestingAuthenticationToken authentication; + private TestingAuthenticationToken principal; + private OAuth2AuthorizationCode authorizationCode; @Before public void setUp() { - this.registeredClientRepository = mock(RegisteredClientRepository.class); - this.authorizationService = mock(OAuth2AuthorizationService.class); - this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class); - this.filter = new OAuth2AuthorizationEndpointFilter( - this.registeredClientRepository, this.authorizationService, this.authorizationConsentService); - this.authentication = new TestingAuthenticationToken("principalName", "password"); - this.authentication.setAuthenticated(true); + this.authenticationManager = mock(AuthenticationManager.class); + this.filter = new OAuth2AuthorizationEndpointFilter(this.authenticationManager); + this.principal = new TestingAuthenticationToken("principalName", "password"); + this.principal.setAuthenticated(true); SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); - securityContext.setAuthentication(this.authentication); + securityContext.setAuthentication(this.principal); SecurityContextHolder.setContext(securityContext); + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); + this.authorizationCode = new OAuth2AuthorizationCode("code", issuedAt, expiresAt); } @After @@ -113,29 +97,15 @@ public class OAuth2AuthorizationEndpointFilterTests { } @Test - public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(null, this.authorizationService, this.authorizationConsentService)) + public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(null)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("registeredClientRepository cannot be null"); - } - - @Test - public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, null, this.authorizationConsentService)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("authorizationService cannot be null"); - } - - @Test - public void constructorWhenAuthorizationConsentServiceNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, (OAuth2AuthorizationConsentService) null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("authorizationConsentService cannot be null"); + .hasMessage("authenticationManager cannot be null"); } @Test public void constructorWhenAuthorizationEndpointUriNullThenThrowIllegalArgumentException() { - assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.registeredClientRepository, this.authorizationService, this.authorizationConsentService, null)) + assertThatThrownBy(() -> new OAuth2AuthorizationEndpointFilter(this.authenticationManager, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("authorizationEndpointUri cannot be empty"); } @@ -153,6 +123,33 @@ public class OAuth2AuthorizationEndpointFilterTests { verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); } + @Test + public void doFilterWhenAuthorizationRequestMissingResponseTypeThenInvalidRequestError() throws Exception { + doFilterWhenAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), + OAuth2ParameterNames.RESPONSE_TYPE, + OAuth2ErrorCodes.INVALID_REQUEST, + request -> request.removeParameter(OAuth2ParameterNames.RESPONSE_TYPE)); + } + + @Test + public void doFilterWhenAuthorizationRequestMultipleResponseTypeThenInvalidRequestError() throws Exception { + doFilterWhenAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), + OAuth2ParameterNames.RESPONSE_TYPE, + OAuth2ErrorCodes.INVALID_REQUEST, + request -> request.addParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token")); + } + + @Test + public void doFilterWhenAuthorizationRequestInvalidResponseTypeThenUnsupportedResponseTypeError() throws Exception { + doFilterWhenAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), + OAuth2ParameterNames.RESPONSE_TYPE, + OAuth2ErrorCodes.UNSUPPORTED_RESPONSE_TYPE, + request -> request.setParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token")); + } + @Test public void doFilterWhenAuthorizationRequestMissingClientIdThenInvalidRequestError() throws Exception { doFilterWhenAuthorizationRequestInvalidParameterThenError( @@ -172,1055 +169,258 @@ public class OAuth2AuthorizationEndpointFilterTests { } @Test - public void doFilterWhenAuthorizationRequestInvalidClientIdThenInvalidRequestError() throws Exception { + public void doFilterWhenAuthorizationRequestMultipleRedirectUriThenInvalidRequestError() throws Exception { doFilterWhenAuthorizationRequestInvalidParameterThenError( TestRegisteredClients.registeredClient().build(), - OAuth2ParameterNames.CLIENT_ID, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.setParameter(OAuth2ParameterNames.CLIENT_ID, "invalid")); - } - - @Test - public void doFilterWhenAuthorizationRequestAndClientNotAuthorizedToRequestCodeThenUnauthorizedClientError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient() - .authorizationGrantTypes(Set::clear) - .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) - .build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.CLIENT_ID, - OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); - } - - @Test - public void doFilterWhenAuthenticationRequestMissingRedirectUriThenInvalidRequestError() throws Exception { - // redirect_uri is REQUIRED for OpenID Connect requests - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.REDIRECT_URI, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.removeParameter(OAuth2ParameterNames.REDIRECT_URI)); - } - - @Test - public void doFilterWhenAuthorizationRequestUnregisteredRedirectUriThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.REDIRECT_URI, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.setParameter(OAuth2ParameterNames.REDIRECT_URI, "https://invalid-example.com")); - } - - // gh-243 - @Test - public void doFilterWhenAuthorizationRequestInvalidRedirectUriHostThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.REDIRECT_URI, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.setParameter(OAuth2ParameterNames.REDIRECT_URI, "https:///invalid")); - } - - @Test - public void doFilterWhenAuthorizationRequestMultipleRedirectUriThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenError( - registeredClient, OAuth2ParameterNames.REDIRECT_URI, OAuth2ErrorCodes.INVALID_REQUEST, request -> request.addParameter(OAuth2ParameterNames.REDIRECT_URI, "https://example2.com")); } @Test - public void doFilterWhenAuthorizationRequestExcludesRedirectUriAndMultipleRegisteredThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUri("https://example2.com").build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - + public void doFilterWhenAuthorizationRequestMultipleScopeThenInvalidRequestError() throws Exception { doFilterWhenAuthorizationRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.REDIRECT_URI, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.removeParameter(OAuth2ParameterNames.REDIRECT_URI)); - } - - @Test - public void doFilterWhenAuthorizationRequestMissingResponseTypeThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, - OAuth2ParameterNames.RESPONSE_TYPE, - OAuth2ErrorCodes.INVALID_REQUEST, - DEFAULT_ERROR_URI, - request -> request.removeParameter(OAuth2ParameterNames.RESPONSE_TYPE)); - } - - @Test - public void doFilterWhenAuthorizationRequestMultipleResponseTypeThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, - OAuth2ParameterNames.RESPONSE_TYPE, - OAuth2ErrorCodes.INVALID_REQUEST, - DEFAULT_ERROR_URI, - request -> request.addParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token")); - } - - @Test - public void doFilterWhenAuthorizationRequestInvalidResponseTypeThenUnsupportedResponseTypeError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, - OAuth2ParameterNames.RESPONSE_TYPE, - OAuth2ErrorCodes.UNSUPPORTED_RESPONSE_TYPE, - DEFAULT_ERROR_URI, - request -> request.setParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token")); - } - - @Test - public void doFilterWhenAuthorizationRequestInvalidScopeThenInvalidScopeError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, + TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.SCOPE, - OAuth2ErrorCodes.INVALID_SCOPE, - DEFAULT_ERROR_URI, - request -> { - String scope = request.getParameter(OAuth2ParameterNames.SCOPE); - request.setParameter(OAuth2ParameterNames.SCOPE, scope + " invalid-scope"); - }); + OAuth2ErrorCodes.INVALID_REQUEST, + request -> request.addParameter(OAuth2ParameterNames.SCOPE, "scope2")); } @Test - public void doFilterWhenPkceRequiredAndMissingCodeChallengeThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient() - .clientSettings(clientSettings -> clientSettings.requireProofKey(true)) - .build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, - PkceParameterNames.CODE_CHALLENGE, + public void doFilterWhenAuthorizationRequestMultipleStateThenInvalidRequestError() throws Exception { + doFilterWhenAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), + OAuth2ParameterNames.STATE, OAuth2ErrorCodes.INVALID_REQUEST, - PKCE_ERROR_URI, - request -> { - addPkceParameters(request); - request.removeParameter(PkceParameterNames.CODE_CHALLENGE); - }); + request -> request.addParameter(OAuth2ParameterNames.STATE, "state2")); } @Test - public void doFilterWhenPkceRequiredAndMultipleCodeChallengeThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient() - .clientSettings(clientSettings -> clientSettings.requireProofKey(true)) - .build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, + public void doFilterWhenAuthorizationRequestMultipleCodeChallengeThenInvalidRequestError() throws Exception { + doFilterWhenAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), PkceParameterNames.CODE_CHALLENGE, OAuth2ErrorCodes.INVALID_REQUEST, - PKCE_ERROR_URI, request -> { - addPkceParameters(request); + request.addParameter(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); request.addParameter(PkceParameterNames.CODE_CHALLENGE, "another-code-challenge"); }); } @Test - public void doFilterWhenPkceNotRequiredAndMultipleCodeChallengeThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, - PkceParameterNames.CODE_CHALLENGE, - OAuth2ErrorCodes.INVALID_REQUEST, - PKCE_ERROR_URI, - request -> { - addPkceParameters(request); - request.addParameter(PkceParameterNames.CODE_CHALLENGE, "another-code-challenge"); - }); - } - - @Test - public void doFilterWhenPkceRequiredAndMultipleCodeChallengeMethodThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient() - .clientSettings(clientSettings -> clientSettings.requireProofKey(true)) - .build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, + public void doFilterWhenAuthorizationRequestMultipleCodeChallengeMethodThenInvalidRequestError() throws Exception { + doFilterWhenAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), PkceParameterNames.CODE_CHALLENGE_METHOD, OAuth2ErrorCodes.INVALID_REQUEST, - PKCE_ERROR_URI, request -> { - addPkceParameters(request); + request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "plain"); }); } @Test - public void doFilterWhenPkceNotRequiredAndMultipleCodeChallengeMethodThenInvalidRequestError() throws Exception { + public void doFilterWhenAuthorizationRequestAuthenticationExceptionThenErrorResponse() throws Exception { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, - PkceParameterNames.CODE_CHALLENGE_METHOD, - OAuth2ErrorCodes.INVALID_REQUEST, - PKCE_ERROR_URI, - request -> { - addPkceParameters(request); - request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "plain"); - }); - } - - @Test - public void doFilterWhenPkceRequiredAndUnsupportedCodeChallengeMethodThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient() - .clientSettings(clientSettings -> clientSettings.requireProofKey(true)) - .build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, - PkceParameterNames.CODE_CHALLENGE_METHOD, - OAuth2ErrorCodes.INVALID_REQUEST, - PKCE_ERROR_URI, - request -> { - addPkceParameters(request); - request.setParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "unsupported"); - }); - } - - @Test - public void doFilterWhenPkceNotRequiredAndUnsupportedCodeChallengeMethodThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenRedirect( - registeredClient, - PkceParameterNames.CODE_CHALLENGE_METHOD, - OAuth2ErrorCodes.INVALID_REQUEST, - PKCE_ERROR_URI, - request -> { - addPkceParameters(request); - request.setParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "unsupported"); - }); - } - - @Test - public void doFilterWhenAuthorizationRequestNotAuthenticatedThenContinueChainToCommenceAuthentication() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + OAuth2Error error = new OAuth2Error("errorCode", "errorDescription", "errorUri"); + when(this.authenticationManager.authenticate(any())) + .thenThrow(new OAuth2AuthorizationCodeRequestAuthenticationException(error, authorizationCodeRequestAuthentication)); MockHttpServletRequest request = createAuthorizationRequest(registeredClient); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); - this.authentication.setAuthenticated(false); + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getRedirectedUrl()).isEqualTo("https://example.com?error=errorCode&error_description=errorDescription&error_uri=errorUri&state=state"); + } + + @Test + public void doFilterWhenAuthorizationRequestPrincipalNotAuthenticatedThenCommenceAuthentication() throws Exception { + this.principal.setAuthenticated(false); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .build(); + authorizationCodeRequestAuthenticationResult.setAuthenticated(false); + when(this.authenticationManager.authenticate(any())) + .thenReturn(authorizationCodeRequestAuthenticationResult); + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); this.filter.doFilter(request, response, filterChain); + verify(this.authenticationManager).authenticate(any()); verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); } @Test - public void doFilterWhenAuthorizationRequestGetThenAuthorizationResponse() throws Exception { + public void doFilterWhenAuthorizationRequestConsentRequiredWithCustomConsentUriThenRedirectConsentResponse() throws Exception { + Set requestedScopes = new HashSet<>(Arrays.asList("scope1", "scope2")); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.addAll(requestedScopes); + }) + .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .scopes(new HashSet<>()) // No scopes previously approved + .consentRequired(true) + .build(); + authorizationCodeRequestAuthenticationResult.setAuthenticated(true); + when(this.authenticationManager.authenticate(any())) + .thenReturn(authorizationCodeRequestAuthenticationResult); + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.setUserConsentUri("/oauth2/custom-consent"); + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getRedirectedUrl()).isEqualTo("http://localhost/oauth2/custom-consent?scope=scope1%20scope2&client_id=client-1&state=state"); + } + + @Test + public void doFilterWhenAuthorizationRequestConsentRequiredThenConsentResponse() throws Exception { + Set requestedScopes = new HashSet<>(Arrays.asList("scope1", "scope2")); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.addAll(requestedScopes); + }) + .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .scopes(new HashSet<>()) // No scopes previously approved + .consentRequired(true) + .build(); + authorizationCodeRequestAuthenticationResult.setAuthenticated(true); + when(this.authenticationManager.authenticate(any())) + .thenReturn(authorizationCodeRequestAuthenticationResult); + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getContentType().equals(new MediaType("text", "html", StandardCharsets.UTF_8).toString())); + for (String requestedScope : requestedScopes) { + assertThat(response.getContentAsString()).contains(scopeCheckbox(requestedScope)); + } + } + + @Test + public void doFilterWhenAuthorizationRequestConsentRequiredWithPreviouslyApprovedThenConsentResponse() throws Exception { + Set approvedScopes = new HashSet<>(Arrays.asList("scope1", "scope2")); + Set requestedScopes = new HashSet<>(Arrays.asList("scope3", "scope4")); + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .scopes(scopes -> { + scopes.clear(); + scopes.addAll(approvedScopes); + scopes.addAll(requestedScopes); + }) + .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .scopes(approvedScopes) + .consentRequired(true) + .build(); + authorizationCodeRequestAuthenticationResult.setAuthenticated(true); + when(this.authenticationManager.authenticate(any())) + .thenReturn(authorizationCodeRequestAuthenticationResult); + + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getContentType().equals(new MediaType("text", "html", StandardCharsets.UTF_8).toString())); + for (String requestedScope : requestedScopes) { + assertThat(response.getContentAsString()).contains(scopeCheckbox(requestedScope)); + } + for (String approvedScope : approvedScopes) { + assertThat(response.getContentAsString()).contains(disabledScopeCheckbox(approvedScope)); + } + } + + @Test + public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .authorizationCode(this.authorizationCode) + .build(); + authorizationCodeRequestAuthenticationResult.setAuthenticated(true); + when(this.authenticationManager.authenticate(any())) + .thenReturn(authorizationCodeRequestAuthenticationResult); + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); - doFilterWhenAuthorizationRequestThenAuthorizationResponse(registeredClient, request); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(response.getRedirectedUrl()).isEqualTo("https://example.com?code=code&state=state"); } @Test - public void doFilterWhenAuthorizationRequestPostThenAuthorizationResponse() throws Exception { - // OpenID Connect requests support POST method - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); - MockHttpServletRequest request = createAuthorizationRequest(registeredClient); - request.setMethod("POST"); - doFilterWhenAuthorizationRequestThenAuthorizationResponse(registeredClient, request); - } - - @Test - public void doFilterWhenAuthenticationRequestIncludesOnlyOpenidScopeThenDoesNotRequireConsent() throws Exception { + public void doFilterWhenAuthenticationRequestAuthenticatedThenAuthorizationResponse() throws Exception { + // Setup OpenID Connect request RegisteredClient registeredClient = TestRegisteredClients.registeredClient() .scopes(scopes -> { scopes.clear(); scopes.add(OidcScopes.OPENID); }) - .clientSettings(clientSettings -> clientSettings.requireUserConsent(true)) .build(); + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = + authorizationCodeRequestAuthentication(registeredClient, this.principal) + .authorizationCode(this.authorizationCode) + .build(); + authorizationCodeRequestAuthenticationResult.setAuthenticated(true); + when(this.authenticationManager.authenticate(any())) + .thenReturn(authorizationCodeRequestAuthenticationResult); + MockHttpServletRequest request = createAuthorizationRequest(registeredClient); - doFilterWhenAuthorizationRequestThenAuthorizationResponse(registeredClient, request); - } - - private void doFilterWhenAuthorizationRequestThenAuthorizationResponse( - RegisteredClient registeredClient, MockHttpServletRequest request) throws Exception { - - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - + request.setMethod("POST"); // OpenID Connect supports POST method MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = mock(FilterChain.class); this.filter.doFilter(request, response, filterChain); + verify(this.authenticationManager).authenticate(any()); verifyNoInteractions(filterChain); assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); - assertThat(response.getRedirectedUrl()).matches("https://example.com\\?code=.{15,}&state=state"); - - ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); - - verify(this.authorizationService).save(authorizationCaptor.capture()); - - OAuth2Authorization authorization = authorizationCaptor.getValue(); - assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId()); - assertThat(authorization.getPrincipalName()).isEqualTo(this.authentication.getPrincipal().toString()); - assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); - assertThat(authorization.getAttribute(Principal.class.getName())) - .isEqualTo(this.authentication); - - OAuth2Authorization.Token authorizationCode = authorization.getToken(OAuth2AuthorizationCode.class); - assertThat(authorizationCode).isNotNull(); - - OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); - assertThat(authorizationRequest).isNotNull(); - - Set authorizedScopes = authorization.getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME); - assertThat(authorizedScopes).isEqualTo(authorizationRequest.getScopes()); - - assertThat(authorizationRequest.getAuthorizationUri()).isEqualTo("http://localhost/oauth2/authorize"); - assertThat(authorizationRequest.getGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); - assertThat(authorizationRequest.getResponseType()).isEqualTo(OAuth2AuthorizationResponseType.CODE); - assertThat(authorizationRequest.getClientId()).isEqualTo(registeredClient.getClientId()); - assertThat(authorizationRequest.getRedirectUri()).isEqualTo(registeredClient.getRedirectUris().iterator().next()); - assertThat(authorizationRequest.getScopes()).containsExactlyInAnyOrderElementsOf(registeredClient.getScopes()); - assertThat(authorizationRequest.getState()).isEqualTo("state"); - assertThat(authorizationRequest.getAdditionalParameters()).isEmpty(); - } - - @Test - public void doFilterWhenPkceRequiredAndAuthorizationRequestThenAuthorizationResponse() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient() - .clientSettings(clientSettings -> clientSettings.requireProofKey(true)) - .build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - MockHttpServletRequest request = createAuthorizationRequest(registeredClient); - addPkceParameters(request); - 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"); - - ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); - - verify(this.authorizationService).save(authorizationCaptor.capture()); - - OAuth2Authorization authorization = authorizationCaptor.getValue(); - assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId()); - assertThat(authorization.getPrincipalName()).isEqualTo(this.authentication.getPrincipal().toString()); - assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); - assertThat(authorization.getAttribute(Principal.class.getName())) - .isEqualTo(this.authentication); - - OAuth2Authorization.Token authorizationCode = authorization.getToken(OAuth2AuthorizationCode.class); - assertThat(authorizationCode).isNotNull(); - - OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); - assertThat(authorizationRequest).isNotNull(); - - Set authorizedScopes = authorization.getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME); - assertThat(authorizedScopes).isEqualTo(authorizationRequest.getScopes()); - - assertThat(authorizationRequest.getClientId()).isEqualTo(registeredClient.getClientId()); - assertThat(authorizationRequest.getAdditionalParameters()) - .size() - .isEqualTo(2) - .returnToMap() - .containsEntry(PkceParameterNames.CODE_CHALLENGE, "code-challenge") - .containsEntry(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); - } - - @Test - public void doFilterWhenUserConsentRequiredAndAuthorizationRequestThenSavesAuthorization() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient() - .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 authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); - - verify(this.authorizationService).save(authorizationCaptor.capture()); - - OAuth2Authorization authorization = authorizationCaptor.getValue(); - assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId()); - assertThat(authorization.getPrincipalName()).isEqualTo(this.authentication.getPrincipal().toString()); - assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); - assertThat(authorization.getAttribute(Principal.class.getName())) - .isEqualTo(this.authentication); - - String state = authorization.getAttribute(OAuth2ParameterNames.STATE); - assertThat(state).isNotNull(); - - Set authorizedScopes = authorization.getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME); - assertThat(authorizedScopes).isNull(); - - OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); - assertThat(authorizationRequest).isNotNull(); - assertThat(authorizationRequest.getAuthorizationUri()).isEqualTo("http://localhost/oauth2/authorize"); - assertThat(authorizationRequest.getGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); - assertThat(authorizationRequest.getResponseType()).isEqualTo(OAuth2AuthorizationResponseType.CODE); - assertThat(authorizationRequest.getClientId()).isEqualTo(registeredClient.getClientId()); - assertThat(authorizationRequest.getRedirectUri()).isEqualTo(registeredClient.getRedirectUris().iterator().next()); - assertThat(authorizationRequest.getScopes()).containsExactlyInAnyOrderElementsOf(registeredClient.getScopes()); - assertThat(authorizationRequest.getState()).isEqualTo("state"); - 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")); - } - - @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 = createAuthorizationConsent( - registeredClient.getClientId(), - this.authentication.getName(), - Arrays.asList(previouslyApprovedScope, unrelatedPreviouslyApprovedScope) - ); - when(this.authorizationConsentService.findById( - eq(registeredClient.getId()), 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("/oauth2/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 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("/oauth2/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 authorizationConsent = createAuthorizationConsent( - registeredClient.getClientId(), this.authentication.getName(), Arrays.asList("message.read", "message.write") - ); - when(this.authorizationConsentService.findById( - eq(registeredClient.getId()), eq(this.authentication.getName()))) - .thenReturn(authorizationConsent); - - 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( - TestRegisteredClients.registeredClient().build(), - OAuth2ParameterNames.STATE, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.removeParameter(OAuth2ParameterNames.STATE)); - } - - @Test - public void doFilterWhenUserConsentRequestMultipleStateThenInvalidRequestError() throws Exception { - doFilterWhenUserConsentRequestInvalidParameterThenError( - TestRegisteredClients.registeredClient().build(), - OAuth2ParameterNames.STATE, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.addParameter(OAuth2ParameterNames.STATE, "state-2")); - } - - @Test - public void doFilterWhenUserConsentRequestInvalidStateThenInvalidRequestError() throws Exception { - doFilterWhenUserConsentRequestInvalidParameterThenError( - TestRegisteredClients.registeredClient().build(), - OAuth2ParameterNames.STATE, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.setParameter(OAuth2ParameterNames.STATE, "invalid")); - } - - @Test - public void doFilterWhenUserConsentRequestNotAuthenticatedThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build(); - when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) - .thenReturn(authorization); - - this.authentication.setAuthenticated(false); - - doFilterWhenUserConsentRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.STATE, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> {}); - } - - @Test - public void doFilterWhenUserConsentRequestInvalidPrincipalThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build(); - when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) - .thenReturn(authorization); - - this.authentication = new TestingAuthenticationToken("other-principal", "password"); - this.authentication.setAuthenticated(true); - SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); - securityContext.setAuthentication(this.authentication); - SecurityContextHolder.setContext(securityContext); - - doFilterWhenUserConsentRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.STATE, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> {}); - } - - @Test - public void doFilterWhenUserConsentRequestMissingClientIdThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) - .principalName(this.authentication.getName()) - .build(); - when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) - .thenReturn(authorization); - - doFilterWhenUserConsentRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.CLIENT_ID, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.removeParameter(OAuth2ParameterNames.CLIENT_ID)); - } - - @Test - public void doFilterWhenUserConsentRequestMultipleClientIdThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) - .principalName(this.authentication.getName()) - .build(); - when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) - .thenReturn(authorization); - - doFilterWhenUserConsentRequestInvalidParameterThenError( - TestRegisteredClients.registeredClient().build(), - OAuth2ParameterNames.CLIENT_ID, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-2")); - } - - @Test - public void doFilterWhenUserConsentRequestInvalidClientIdThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) - .principalName(this.authentication.getName()) - .build(); - when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) - .thenReturn(authorization); - - doFilterWhenUserConsentRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.CLIENT_ID, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.setParameter(OAuth2ParameterNames.CLIENT_ID, "invalid")); - } - - @Test - public void doFilterWhenUserConsentRequestDoesNotMatchClientThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - RegisteredClient otherRegisteredClient = TestRegisteredClients.registeredClient2().build(); - OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(otherRegisteredClient) - .principalName(this.authentication.getName()) - .build(); - when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) - .thenReturn(authorization); - - doFilterWhenUserConsentRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.CLIENT_ID, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> {}); - } - - @Test - public void doFilterWhenUserConsentRequestInvalidScopeThenInvalidScopeError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) - .principalName(this.authentication.getName()) - .build(); - when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) - .thenReturn(authorization); - - doFilterWhenUserConsentRequestInvalidParameterThenRedirect( - registeredClient, - OAuth2ParameterNames.SCOPE, - OAuth2ErrorCodes.INVALID_SCOPE, - DEFAULT_ERROR_URI, - request -> { - request.addParameter(OAuth2ParameterNames.SCOPE, "invalid-scope"); - }); - } - - @Test - public void doFilterWhenUserConsentRequestNotApprovedThenAccessDeniedError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient) - .principalName(this.authentication.getName()) - .build(); - when(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))) - .thenReturn(authorization); - - doFilterWhenUserConsentRequestInvalidParameterThenRedirect( - registeredClient, - OAuth2ParameterNames.CLIENT_ID, - OAuth2ErrorCodes.ACCESS_DENIED, - DEFAULT_ERROR_URI, - request -> request.setParameter("consent_action", "cancel")); - - verify(this.authorizationService).remove(eq(authorization)); - } - - @Test - public void doFilterWhenUserConsentRequestApprovedThenAuthorizationResponse() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); - when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) - .thenReturn(registeredClient); - 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); - - MockHttpServletRequest request = createUserConsentRequest(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"); - - ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); - - verify(this.authorizationService).save(authorizationCaptor.capture()); - - OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue(); - assertThat(updatedAuthorization.getRegisteredClientId()).isEqualTo(registeredClient.getId()); - assertThat(updatedAuthorization.getPrincipalName()).isEqualTo(this.authentication.getPrincipal().toString()); - assertThat(updatedAuthorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); - assertThat(updatedAuthorization.getToken(OAuth2AuthorizationCode.class)).isNotNull(); - assertThat(updatedAuthorization.getAttribute(OAuth2ParameterNames.STATE)).isNull(); - assertThat(updatedAuthorization.getAttribute(OAuth2AuthorizationRequest.class.getName())) - .isEqualTo(authorization.getAttribute(OAuth2AuthorizationRequest.class.getName())); - assertThat(updatedAuthorization.>getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME)) - .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 authorizationConsentCaptor = ArgumentCaptor.forClass(OAuth2AuthorizationConsent.class); - - verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture()); - OAuth2AuthorizationConsent authorizationConsent = authorizationConsentCaptor.getValue(); - assertThat(authorizationConsent.getPrincipalName()).isEqualTo(this.authentication.getName()); - assertThat(authorizationConsent.getRegisteredClientId()).isEqualTo(registeredClient.getId()); - assertThat(authorizationConsent.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.authorizationConsentService, 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 previousAuthorizationConsent = - createAuthorizationConsent( - registeredClient.getClientId(), - this.authentication.getName(), - Collections.singleton("message.read") - ); - when(this.authorizationConsentService.findById( - eq(registeredClient.getId()), eq(this.authentication.getName()))) - .thenReturn(previousAuthorizationConsent); - - MockHttpServletRequest request = createUserConsentRequest(registeredClient); - MockHttpServletResponse response = new MockHttpServletResponse(); - FilterChain filterChain = mock(FilterChain.class); - - this.filter.doFilter(request, response, filterChain); - - ArgumentCaptor authorizationConsentCaptor = ArgumentCaptor.forClass(OAuth2AuthorizationConsent.class); - - verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture()); - OAuth2AuthorizationConsent authorizationConsent = authorizationConsentCaptor.getValue(); - assertThat(authorizationConsent.getRegisteredClientId()).isEqualTo(registeredClient.getClientId()); - assertThat(authorizationConsent.getPrincipalName()).isEqualTo(this.authentication.getName()); - assertThat(authorizationConsent.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 previousAuthorizationConsent = - createAuthorizationConsent( - registeredClient.getClientId(), - this.authentication.getName(), - Arrays.asList(previouslyApprovedScope, unrelatedPreviouslyApprovedScope) - ); - when(this.authorizationConsentService.findById( - eq(registeredClient.getId()), eq(this.authentication.getName()))) - .thenReturn(previousAuthorizationConsent); - - MockHttpServletRequest request = createUserConsentRequest(registeredClient); - MockHttpServletResponse response = new MockHttpServletResponse(); - FilterChain filterChain = mock(FilterChain.class); - - this.filter.doFilter(request, response, filterChain); - - ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); - - verify(this.authorizationService).save(authorizationCaptor.capture()); - Set savedAuthorizationScopes = authorizationCaptor.getValue().getAttribute(OAuth2Authorization.AUTHORIZED_SCOPE_ATTRIBUTE_NAME); - assertThat(savedAuthorizationScopes).containsExactlyInAnyOrder(newScope, previouslyApprovedScope); - assertThat(savedAuthorizationScopes).doesNotContain(unrelatedPreviouslyApprovedScope); - } - - // gh-243 - @Test - public void doFilterWhenAuthorizationRequestIPv4LoopbackRedirectUriAndDifferentPortThenAuthorizationResponse() - throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient() - .redirectUri("http://127.0.0.1:8080") - .build(); - MockHttpServletRequest request = createAuthorizationRequest(registeredClient); - request.removeParameter(OAuth2ParameterNames.REDIRECT_URI); - request.addParameter(OAuth2ParameterNames.REDIRECT_URI, "http://127.0.0.1:5000"); - - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(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("http://127.0.0.1:5000\\?code=.{15,}&state=state"); - } - - // gh-243 - @Test - public void doFilterWhenAuthorizationRequestIPv6LoopbackRedirectUriAndDifferentPortThenAuthorizationResponse() - throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient() - .redirectUri("http://[::1]:8080") - .build(); - MockHttpServletRequest request = createAuthorizationRequest(registeredClient); - request.removeParameter(OAuth2ParameterNames.REDIRECT_URI); - request.addParameter(OAuth2ParameterNames.REDIRECT_URI, "http://[::1]:5000"); - - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(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("http://\\[::1]:5000\\?code=.{15,}&state=state"); - } - - // gh-243 - @Test - public void doFilterWhenAuthorizationRequestInvalidRedirectUriFragmentThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.REDIRECT_URI, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.setParameter(OAuth2ParameterNames.REDIRECT_URI, "https://example.com#fragment")); - } - - // gh-243 - @Test - public void doFilterWhenAuthorizationRequestLocalhostRedirectUriThenInvalidRequestError() throws Exception { - RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); - when(this.registeredClientRepository.findByClientId((eq(registeredClient.getClientId())))) - .thenReturn(registeredClient); - - doFilterWhenAuthorizationRequestInvalidParameterThenError( - registeredClient, - OAuth2ParameterNames.REDIRECT_URI, - OAuth2ErrorCodes.INVALID_REQUEST, - request -> request.setParameter(OAuth2ParameterNames.REDIRECT_URI, "http://localhost:5000")); - } - - private void doFilterWhenAuthorizationRequestInvalidParameterThenError(RegisteredClient registeredClient, - String parameterName, String errorCode) throws Exception { - doFilterWhenAuthorizationRequestInvalidParameterThenError(registeredClient, parameterName, errorCode, request -> {}); + assertThat(response.getRedirectedUrl()).isEqualTo("https://example.com?code=code&state=state"); } private void doFilterWhenAuthorizationRequestInvalidParameterThenError(RegisteredClient registeredClient, @@ -1230,29 +430,6 @@ public class OAuth2AuthorizationEndpointFilterTests { parameterName, errorCode, requestConsumer); } - private void doFilterWhenAuthorizationRequestInvalidParameterThenRedirect(RegisteredClient registeredClient, - String parameterName, String errorCode, String errorUri, - Consumer requestConsumer) throws Exception { - - doFilterWhenRequestInvalidParameterThenRedirect(createAuthorizationRequest(registeredClient), - parameterName, errorCode, errorUri, requestConsumer); - } - - private void doFilterWhenUserConsentRequestInvalidParameterThenError(RegisteredClient registeredClient, - String parameterName, String errorCode, Consumer requestConsumer) throws Exception { - - doFilterWhenRequestInvalidParameterThenError(createUserConsentRequest(registeredClient), - parameterName, errorCode, requestConsumer); - } - - private void doFilterWhenUserConsentRequestInvalidParameterThenRedirect(RegisteredClient registeredClient, - String parameterName, String errorCode, String errorUri, - Consumer requestConsumer) throws Exception { - - doFilterWhenRequestInvalidParameterThenRedirect(createUserConsentRequest(registeredClient), - parameterName, errorCode, errorUri, requestConsumer); - } - private void doFilterWhenRequestInvalidParameterThenError(MockHttpServletRequest request, String parameterName, String errorCode, Consumer requestConsumer) throws Exception { @@ -1268,36 +445,14 @@ public class OAuth2AuthorizationEndpointFilterTests { assertThat(response.getErrorMessage()).isEqualTo("[" + errorCode + "] OAuth 2.0 Parameter: " + parameterName); } - private void doFilterWhenRequestInvalidParameterThenRedirect(MockHttpServletRequest request, - String parameterName, String errorCode, String errorUri, - Consumer requestConsumer) throws Exception { - - requestConsumer.accept(request); - 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\\?" + - "error=" + errorCode + "&" + - "error_description=OAuth%202.0%20Parameter:%20" + parameterName + "&" + - "error_uri=" + errorUri + "&" + - "state=state"); - } - private static MockHttpServletRequest createAuthorizationRequest(RegisteredClient registeredClient) { - String[] redirectUris = registeredClient.getRedirectUris().toArray(new String[0]); - String requestUri = OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI; MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri); request.setServletPath(requestUri); request.addParameter(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue()); request.addParameter(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); - request.addParameter(OAuth2ParameterNames.REDIRECT_URI, redirectUris[0]); + request.addParameter(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next()); request.addParameter(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); request.addParameter(OAuth2ParameterNames.STATE, "state"); @@ -1305,36 +460,13 @@ public class OAuth2AuthorizationEndpointFilterTests { return request; } - private static void addPkceParameters(MockHttpServletRequest request) { - request.addParameter(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); - request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); - } - - private static OAuth2AuthorizationConsent createAuthorizationConsent(String registeredClientId, - String principalName, Collection scopes) { - OAuth2AuthorizationConsent.Builder authorizationConsentBuilder = - OAuth2AuthorizationConsent.withId(registeredClientId, principalName); - for (String scope : scopes) { - authorizationConsentBuilder.scope(scope); - } - return authorizationConsentBuilder.build(); - } - - private static MockHttpServletRequest createUserConsentRequest(RegisteredClient registeredClient) { - String requestUri = OAuth2AuthorizationEndpointFilter.DEFAULT_AUTHORIZATION_ENDPOINT_URI; - MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); - request.setServletPath(requestUri); - - request.addParameter(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); - request.addParameter(OAuth2ParameterNames.STATE, "state"); - for (String scope : registeredClient.getScopes()) { - if (!OidcScopes.OPENID.equals(scope)) { - request.addParameter(OAuth2ParameterNames.SCOPE, scope); - } - } - request.addParameter("consent_action", "approve"); - - return request; + private static OAuth2AuthorizationCodeRequestAuthenticationToken.Builder authorizationCodeRequestAuthentication( + RegisteredClient registeredClient, Authentication principal) { + return OAuth2AuthorizationCodeRequestAuthenticationToken.with(registeredClient.getClientId(), principal) + .authorizationUri("https://provider.com/oauth2/authorize") + .redirectUri(registeredClient.getRedirectUris().iterator().next()) + .scopes(registeredClient.getScopes()) + .state("state"); } private static String scopeCheckbox(String scope) {