From 559c847bdfb4844f019ac9eb0649debfb37befc1 Mon Sep 17 00:00:00 2001 From: Joe Grandja <10884212+jgrandja@users.noreply.github.com> Date: Mon, 3 Mar 2025 17:09:54 -0500 Subject: [PATCH] Add support for OAuth 2.0 Pushed Authorization Requests (PAR) Closes gh-210 Signed-off-by: Joe Grandja <10884212+jgrandja@users.noreply.github.com> --- etc/checkstyle/checkstyle-suppressions.xml | 1 + ...izationCodeRequestAuthenticationToken.java | 135 +++++ .../JwtClientAssertionDecoderFactory.java | 4 +- ...tionCodeRequestAuthenticationProvider.java | 110 ++-- ...izationCodeRequestAuthenticationToken.java | 112 +--- ...ionCodeRequestAuthenticationValidator.java | 108 +++- ...rizationRequestAuthenticationProvider.java | 184 +++++++ ...thorizationRequestAuthenticationToken.java | 94 ++++ .../OAuth2PushedAuthorizationRequestUri.java | 80 +++ .../authentication/OidcPrompt.java | 38 ++ .../OAuth2AuthorizationServerConfigurer.java | 71 ++- .../OAuth2ClientAuthenticationConfigurer.java | 9 +- ...uthorizationRequestEndpointConfigurer.java | 265 ++++++++++ .../settings/AuthorizationServerSettings.java | 25 +- .../settings/ConfigurationSettingNames.java | 9 +- .../web/HttpMessageConverters.java | 63 +++ ...hedAuthorizationRequestEndpointFilter.java | 223 ++++++++ ...ionCodeRequestAuthenticationConverter.java | 60 ++- ...odeRequestAuthenticationProviderTests.java | 72 ++- ...ionRequestAuthenticationProviderTests.java | 422 +++++++++++++++ .../OAuth2AuthorizationCodeGrantTests.java | 87 ++++ .../AuthorizationServerSettingsTests.java | 17 +- ...Auth2AuthorizationEndpointFilterTests.java | 16 + ...thorizationRequestEndpointFilterTests.java | 490 ++++++++++++++++++ 24 files changed, 2481 insertions(+), 214 deletions(-) create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/AbstractOAuth2AuthorizationCodeRequestAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProvider.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestUri.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcPrompt.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2PushedAuthorizationRequestEndpointConfigurer.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/HttpMessageConverters.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2PushedAuthorizationRequestEndpointFilter.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProviderTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2PushedAuthorizationRequestEndpointFilterTests.java diff --git a/etc/checkstyle/checkstyle-suppressions.xml b/etc/checkstyle/checkstyle-suppressions.xml index 8f3d30181..3f5f00e64 100644 --- a/etc/checkstyle/checkstyle-suppressions.xml +++ b/etc/checkstyle/checkstyle-suppressions.xml @@ -7,4 +7,5 @@ + diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/AbstractOAuth2AuthorizationCodeRequestAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/AbstractOAuth2AuthorizationCodeRequestAuthenticationToken.java new file mode 100644 index 000000000..111f213a5 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/AbstractOAuth2AuthorizationCodeRequestAuthenticationToken.java @@ -0,0 +1,135 @@ +/* + * Copyright 2020-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} base implementation for the OAuth 2.0 Authorization Request + * used in the Authorization Code Grant. + * + * @author Joe Grandja + * @since 1.5 + * @see OAuth2AuthorizationCodeRequestAuthenticationToken + * @see OAuth2PushedAuthorizationRequestAuthenticationToken + */ +abstract class AbstractOAuth2AuthorizationCodeRequestAuthenticationToken extends AbstractAuthenticationToken { + + private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; + + private final String authorizationUri; + + private final String clientId; + + private final Authentication principal; + + private final String redirectUri; + + private final String state; + + private final Set scopes; + + private final Map additionalParameters; + + protected AbstractOAuth2AuthorizationCodeRequestAuthenticationToken(String authorizationUri, String clientId, + Authentication principal, @Nullable String redirectUri, @Nullable String state, + @Nullable Set scopes, @Nullable Map additionalParameters) { + super(Collections.emptyList()); + Assert.hasText(authorizationUri, "authorizationUri cannot be empty"); + Assert.hasText(clientId, "clientId cannot be empty"); + Assert.notNull(principal, "principal cannot be null"); + this.authorizationUri = authorizationUri; + this.clientId = clientId; + this.principal = principal; + this.redirectUri = redirectUri; + this.state = state; + this.scopes = Collections.unmodifiableSet((scopes != null) ? new HashSet<>(scopes) : Collections.emptySet()); + this.additionalParameters = Collections.unmodifiableMap( + (additionalParameters != null) ? new HashMap<>(additionalParameters) : Collections.emptyMap()); + } + + @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 state. + * @return the state + */ + @Nullable + public String getState() { + return this.state; + } + + /** + * 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 additional parameters. + * @return the additional parameters, or an empty {@code Map} if not available + */ + public Map getAdditionalParameters() { + return this.additionalParameters; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactory.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactory.java index c2e18ad6c..f7738ccb8 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactory.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2023 the original author or authors. + * Copyright 2020-2025 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. @@ -206,6 +206,8 @@ private static List getAudience() { authorizationServerSettings.getTokenIntrospectionEndpoint())); audience.add(asUrl(authorizationServerContext.getIssuer(), authorizationServerSettings.getTokenRevocationEndpoint())); + audience.add(asUrl(authorizationServerContext.getIssuer(), + authorizationServerSettings.getPushedAuthorizationRequestEndpoint())); return audience; } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java index 1f3cef97d..87e96b80a 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-2025 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. @@ -27,7 +27,6 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.springframework.core.log.LogMessage; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; @@ -39,7 +38,6 @@ import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; 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; @@ -81,7 +79,7 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; - private static final String PKCE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1"; + private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); private static final StringKeyGenerator DEFAULT_STATE_GENERATOR = new Base64StringKeyGenerator( Base64.getUrlEncoder()); @@ -122,6 +120,13 @@ public OAuth2AuthorizationCodeRequestAuthenticationProvider(RegisteredClientRepo public Authentication authenticate(Authentication authentication) throws AuthenticationException { OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication; + String requestUri = (String) authorizationCodeRequestAuthentication.getAdditionalParameters() + .get("request_uri"); + if (StringUtils.hasText(requestUri)) { + authorizationCodeRequestAuthentication = fromPushedAuthorizationRequest( + authorizationCodeRequestAuthentication); + } + RegisteredClient registeredClient = this.registeredClientRepository .findByClientId(authorizationCodeRequestAuthentication.getClientId()); if (registeredClient == null) { @@ -136,47 +141,28 @@ public Authentication authenticate(Authentication authentication) throws Authent OAuth2AuthorizationCodeRequestAuthenticationContext.Builder authenticationContextBuilder = OAuth2AuthorizationCodeRequestAuthenticationContext .with(authorizationCodeRequestAuthentication) .registeredClient(registeredClient); - this.authenticationValidator.accept(authenticationContextBuilder.build()); + OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext = authenticationContextBuilder + .build(); - if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE)) { - if (this.logger.isDebugEnabled()) { - this.logger.debug(LogMessage.format( - "Invalid request: requested grant_type is not allowed" + " for registered client '%s'", - registeredClient.getId())); - } - throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID, - authorizationCodeRequestAuthentication, registeredClient); - } + // grant_type + OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_AUTHORIZATION_GRANT_TYPE_VALIDATOR + .accept(authenticationContext); + + // redirect_uri and scope + this.authenticationValidator.accept(authenticationContext); // 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) || !"S256".equals(codeChallengeMethod)) { - throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI, - authorizationCodeRequestAuthentication, registeredClient, null); - } - } - else if (registeredClient.getClientSettings().isRequireProofKey()) { - throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI, - authorizationCodeRequestAuthentication, registeredClient, null); - } + OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_CODE_CHALLENGE_VALIDATOR + .accept(authenticationContext); // prompt (OPTIONAL for OpenID Connect 1.0 Authentication Request) Set promptValues = Collections.emptySet(); if (authorizationCodeRequestAuthentication.getScopes().contains(OidcScopes.OPENID)) { String prompt = (String) authorizationCodeRequestAuthentication.getAdditionalParameters().get("prompt"); if (StringUtils.hasText(prompt)) { + OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_PROMPT_VALIDATOR + .accept(authenticationContext); promptValues = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(prompt, " "))); - if (promptValues.contains(OidcPrompts.NONE)) { - if (promptValues.contains(OidcPrompts.LOGIN) || promptValues.contains(OidcPrompts.CONSENT) - || promptValues.contains(OidcPrompts.SELECT_ACCOUNT)) { - throwError(OAuth2ErrorCodes.INVALID_REQUEST, "prompt", authorizationCodeRequestAuthentication, - registeredClient); - } - } } } @@ -190,7 +176,7 @@ else if (registeredClient.getClientSettings().isRequireProofKey()) { Authentication principal = (Authentication) authorizationCodeRequestAuthentication.getPrincipal(); if (!isPrincipalAuthenticated(principal)) { - if (promptValues.contains(OidcPrompts.NONE)) { + if (promptValues.contains(OidcPrompt.NONE)) { // Return an error instead of displaying the login page (via the // configured AuthenticationEntryPoint) throwError("login_required", "prompt", authorizationCodeRequestAuthentication, registeredClient); @@ -219,7 +205,7 @@ else if (registeredClient.getClientSettings().isRequireProofKey()) { } if (this.authorizationConsentRequired.test(authenticationContextBuilder.build())) { - if (promptValues.contains(OidcPrompts.NONE)) { + if (promptValues.contains(OidcPrompt.NONE)) { // Return an error instead of displaying the consent page throwError("consent_required", "prompt", authorizationCodeRequestAuthentication, registeredClient); } @@ -347,6 +333,37 @@ public void setAuthorizationConsentRequired( this.authorizationConsentRequired = authorizationConsentRequired; } + private OAuth2AuthorizationCodeRequestAuthenticationToken fromPushedAuthorizationRequest( + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication) { + + String requestUri = (String) authorizationCodeRequestAuthentication.getAdditionalParameters() + .get("request_uri"); + + OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = null; + try { + pushedAuthorizationRequestUri = OAuth2PushedAuthorizationRequestUri.parse(requestUri); + } + catch (Exception ex) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, "request_uri", authorizationCodeRequestAuthentication, null); + } + + OAuth2Authorization authorization = this.authorizationService + .findByToken(pushedAuthorizationRequestUri.getState(), STATE_TOKEN_TYPE); + if (authorization == null) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, "request_uri", authorizationCodeRequestAuthentication, null); + } + + OAuth2AuthorizationRequest authorizationRequest = authorization + .getAttribute(OAuth2AuthorizationRequest.class.getName()); + + return new OAuth2AuthorizationCodeRequestAuthenticationToken( + authorizationCodeRequestAuthentication.getAuthorizationUri(), + authorizationCodeRequestAuthentication.getClientId(), + (Authentication) authorizationCodeRequestAuthentication.getPrincipal(), + authorizationRequest.getRedirectUri(), authorizationRequest.getState(), + authorizationRequest.getScopes(), authorizationRequest.getAdditionalParameters()); + } + private static boolean isAuthorizationConsentRequired( OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) { if (!authenticationContext.getRegisteredClient().getClientSettings().isRequireAuthorizationConsent()) { @@ -457,23 +474,4 @@ private static String resolveRedirectUri( return null; } - /* - * The values defined for the "prompt" parameter for the OpenID Connect 1.0 - * Authentication Request. - */ - private static final class OidcPrompts { - - private static final String NONE = "none"; - - private static final String LOGIN = "login"; - - private static final String CONSENT = "consent"; - - private static final String SELECT_ACCOUNT = "select_account"; - - private OidcPrompts() { - } - - } - } 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 index ebf3121b7..210e011c7 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2025 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. @@ -15,17 +15,12 @@ */ package org.springframework.security.oauth2.server.authorization.authentication; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; import java.util.Map; import java.util.Set; import org.springframework.lang.Nullable; -import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; -import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; import org.springframework.util.Assert; /** @@ -37,23 +32,8 @@ * @see OAuth2AuthorizationCodeRequestAuthenticationProvider * @see OAuth2AuthorizationConsentAuthenticationProvider */ -public class OAuth2AuthorizationCodeRequestAuthenticationToken extends AbstractAuthenticationToken { - - private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; - - private final String authorizationUri; - - private final String clientId; - - private final Authentication principal; - - private final String redirectUri; - - private final String state; - - private final Set scopes; - - private final Map additionalParameters; +public class OAuth2AuthorizationCodeRequestAuthenticationToken + extends AbstractOAuth2AuthorizationCodeRequestAuthenticationToken { private final OAuth2AuthorizationCode authorizationCode; @@ -72,18 +52,7 @@ public class OAuth2AuthorizationCodeRequestAuthenticationToken extends AbstractA public OAuth2AuthorizationCodeRequestAuthenticationToken(String authorizationUri, String clientId, Authentication principal, @Nullable String redirectUri, @Nullable String state, @Nullable Set scopes, @Nullable Map additionalParameters) { - super(Collections.emptyList()); - Assert.hasText(authorizationUri, "authorizationUri cannot be empty"); - Assert.hasText(clientId, "clientId cannot be empty"); - Assert.notNull(principal, "principal cannot be null"); - this.authorizationUri = authorizationUri; - this.clientId = clientId; - this.principal = principal; - this.redirectUri = redirectUri; - this.state = state; - this.scopes = Collections.unmodifiableSet((scopes != null) ? new HashSet<>(scopes) : Collections.emptySet()); - this.additionalParameters = Collections.unmodifiableMap( - (additionalParameters != null) ? new HashMap<>(additionalParameters) : Collections.emptyMap()); + super(authorizationUri, clientId, principal, redirectUri, state, scopes, additionalParameters); this.authorizationCode = null; } @@ -102,83 +71,12 @@ public OAuth2AuthorizationCodeRequestAuthenticationToken(String authorizationUri public OAuth2AuthorizationCodeRequestAuthenticationToken(String authorizationUri, String clientId, Authentication principal, OAuth2AuthorizationCode authorizationCode, @Nullable String redirectUri, @Nullable String state, @Nullable Set scopes) { - super(Collections.emptyList()); - Assert.hasText(authorizationUri, "authorizationUri cannot be empty"); - Assert.hasText(clientId, "clientId cannot be empty"); - Assert.notNull(principal, "principal cannot be null"); + super(authorizationUri, clientId, principal, redirectUri, state, scopes, null); Assert.notNull(authorizationCode, "authorizationCode cannot be null"); - this.authorizationUri = authorizationUri; - this.clientId = clientId; - this.principal = principal; this.authorizationCode = authorizationCode; - this.redirectUri = redirectUri; - this.state = state; - this.scopes = Collections.unmodifiableSet((scopes != null) ? new HashSet<>(scopes) : Collections.emptySet()); - this.additionalParameters = Collections.emptyMap(); setAuthenticated(true); } - @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 state. - * @return the state - */ - @Nullable - public String getState() { - return this.state; - } - - /** - * 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 additional parameters. - * @return the additional parameters, or an empty {@code Map} if not available - */ - public Map getAdditionalParameters() { - return this.additionalParameters; - } - /** * Returns the {@link OAuth2AuthorizationCode}. * @return the {@link OAuth2AuthorizationCode} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationValidator.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationValidator.java index 3b8eb1b42..89d6c130c 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationValidator.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationValidator.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2023 the original author or authors. + * Copyright 2020-2025 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. @@ -15,6 +15,8 @@ */ package org.springframework.security.oauth2.server.authorization.authentication; +import java.util.Arrays; +import java.util.HashSet; import java.util.Set; import java.util.function.Consumer; @@ -23,9 +25,11 @@ import org.springframework.core.log.LogMessage; 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.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.client.RegisteredClient; import org.springframework.util.StringUtils; @@ -51,25 +55,34 @@ * @see OAuth2AuthorizationCodeRequestAuthenticationContext * @see OAuth2AuthorizationCodeRequestAuthenticationToken * @see OAuth2AuthorizationCodeRequestAuthenticationProvider#setAuthenticationValidator(Consumer) + * @see OAuth2PushedAuthorizationRequestAuthenticationProvider#setAuthenticationValidator(Consumer) */ public final class OAuth2AuthorizationCodeRequestAuthenticationValidator implements Consumer { private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; + private static final String PKCE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1"; + private static final Log LOGGER = LogFactory.getLog(OAuth2AuthorizationCodeRequestAuthenticationValidator.class); + static final Consumer DEFAULT_AUTHORIZATION_GRANT_TYPE_VALIDATOR = OAuth2AuthorizationCodeRequestAuthenticationValidator::validateAuthorizationGrantType; + + static final Consumer DEFAULT_CODE_CHALLENGE_VALIDATOR = OAuth2AuthorizationCodeRequestAuthenticationValidator::validateCodeChallenge; + + static final Consumer DEFAULT_PROMPT_VALIDATOR = OAuth2AuthorizationCodeRequestAuthenticationValidator::validatePrompt; + /** * The default validator for - * {@link OAuth2AuthorizationCodeRequestAuthenticationToken#getScopes()}. + * {@link OAuth2AuthorizationCodeRequestAuthenticationToken#getRedirectUri()}. */ - public static final Consumer DEFAULT_SCOPE_VALIDATOR = OAuth2AuthorizationCodeRequestAuthenticationValidator::validateScope; + public static final Consumer DEFAULT_REDIRECT_URI_VALIDATOR = OAuth2AuthorizationCodeRequestAuthenticationValidator::validateRedirectUri; /** * The default validator for - * {@link OAuth2AuthorizationCodeRequestAuthenticationToken#getRedirectUri()}. + * {@link OAuth2AuthorizationCodeRequestAuthenticationToken#getScopes()}. */ - public static final Consumer DEFAULT_REDIRECT_URI_VALIDATOR = OAuth2AuthorizationCodeRequestAuthenticationValidator::validateRedirectUri; + public static final Consumer DEFAULT_SCOPE_VALIDATOR = OAuth2AuthorizationCodeRequestAuthenticationValidator::validateScope; private final Consumer authenticationValidator = DEFAULT_REDIRECT_URI_VALIDATOR .andThen(DEFAULT_SCOPE_VALIDATOR); @@ -79,20 +92,18 @@ public void accept(OAuth2AuthorizationCodeRequestAuthenticationContext authentic this.authenticationValidator.accept(authenticationContext); } - private static void validateScope(OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) { + private static void validateAuthorizationGrantType( + OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) { OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationContext .getAuthentication(); RegisteredClient registeredClient = authenticationContext.getRegisteredClient(); - - Set requestedScopes = authorizationCodeRequestAuthentication.getScopes(); - Set allowedScopes = registeredClient.getScopes(); - if (!requestedScopes.isEmpty() && !allowedScopes.containsAll(requestedScopes)) { + if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE)) { if (LOGGER.isDebugEnabled()) { LOGGER.debug(LogMessage.format( - "Invalid request: requested scope is not allowed" + " for registered client '%s'", + "Invalid request: requested grant_type is not allowed for registered client '%s'", registeredClient.getId())); } - throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, + throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID, authorizationCodeRequestAuthentication, registeredClient); } } @@ -151,7 +162,7 @@ private static void validateRedirectUri(OAuth2AuthorizationCodeRequestAuthentica if (!validRedirectUri) { if (LOGGER.isDebugEnabled()) { LOGGER.debug(LogMessage.format( - "Invalid request: redirect_uri does not match" + " for registered client '%s'", + "Invalid request: redirect_uri does not match for registered client '%s'", registeredClient.getId())); } throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, @@ -172,6 +183,69 @@ private static void validateRedirectUri(OAuth2AuthorizationCodeRequestAuthentica } } + private static void validateScope(OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationContext + .getAuthentication(); + RegisteredClient registeredClient = authenticationContext.getRegisteredClient(); + + Set requestedScopes = authorizationCodeRequestAuthentication.getScopes(); + Set allowedScopes = registeredClient.getScopes(); + if (!requestedScopes.isEmpty() && !allowedScopes.containsAll(requestedScopes)) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + LogMessage.format("Invalid request: requested scope is not allowed for registered client '%s'", + registeredClient.getId())); + } + throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, + authorizationCodeRequestAuthentication, registeredClient); + } + } + + private static void validateCodeChallenge( + OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationContext + .getAuthentication(); + RegisteredClient registeredClient = authenticationContext.getRegisteredClient(); + + // 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) || !"S256".equals(codeChallengeMethod)) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI, + authorizationCodeRequestAuthentication, registeredClient); + } + } + else if (registeredClient.getClientSettings().isRequireProofKey()) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI, + authorizationCodeRequestAuthentication, registeredClient); + } + } + + private static void validatePrompt(OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationContext + .getAuthentication(); + RegisteredClient registeredClient = authenticationContext.getRegisteredClient(); + + // prompt (OPTIONAL for OpenID Connect 1.0 Authentication Request) + if (authorizationCodeRequestAuthentication.getScopes().contains(OidcScopes.OPENID)) { + String prompt = (String) authorizationCodeRequestAuthentication.getAdditionalParameters().get("prompt"); + if (StringUtils.hasText(prompt)) { + Set promptValues = new HashSet<>( + Arrays.asList(StringUtils.delimitedListToStringArray(prompt, " "))); + if (promptValues.contains(OidcPrompt.NONE)) { + if (promptValues.contains(OidcPrompt.LOGIN) || promptValues.contains(OidcPrompt.CONSENT) + || promptValues.contains(OidcPrompt.SELECT_ACCOUNT)) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, "prompt", authorizationCodeRequestAuthentication, + registeredClient); + } + } + } + } + } + private static boolean isLoopbackAddress(String host) { if (!StringUtils.hasText(host)) { return false; @@ -201,7 +275,13 @@ private static boolean isLoopbackAddress(String host) { private static void throwError(String errorCode, String parameterName, OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, RegisteredClient registeredClient) { - OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, ERROR_URI); + throwError(errorCode, parameterName, ERROR_URI, authorizationCodeRequestAuthentication, registeredClient); + } + + private static void throwError(String errorCode, String parameterName, String errorUri, + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication, + RegisteredClient registeredClient) { + OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, errorUri); throwError(error, parameterName, authorizationCodeRequestAuthentication, registeredClient); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProvider.java new file mode 100644 index 000000000..e41cb2a0f --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProvider.java @@ -0,0 +1,184 @@ +/* + * Copyright 2020-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.function.Consumer; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} implementation for the OAuth 2.0 Pushed Authorization + * Request used in the Authorization Code Grant. + * + * @author Joe Grandja + * @since 1.5 + * @see OAuth2PushedAuthorizationRequestAuthenticationToken + * @see OAuth2AuthorizationCodeRequestAuthenticationToken + * @see OAuth2AuthorizationCodeRequestAuthenticationValidator + * @see OAuth2AuthorizationService + * @see Section 2.1 Pushed + * Authorization Request + * @see Section 2.2 Pushed + * Authorization Response + */ +public final class OAuth2PushedAuthorizationRequestAuthenticationProvider implements AuthenticationProvider { + + private final Log logger = LogFactory.getLog(getClass()); + + private final OAuth2AuthorizationService authorizationService; + + private Consumer authenticationValidator = new OAuth2AuthorizationCodeRequestAuthenticationValidator(); + + /** + * Constructs an {@code OAuth2PushedAuthorizationRequestAuthenticationProvider} using + * the provided parameters. + * @param authorizationService the authorization service + */ + public OAuth2PushedAuthorizationRequestAuthenticationProvider(OAuth2AuthorizationService authorizationService) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + this.authorizationService = authorizationService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2PushedAuthorizationRequestAuthenticationToken pushedAuthorizationRequestAuthentication = (OAuth2PushedAuthorizationRequestAuthenticationToken) authentication; + + OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils + .getAuthenticatedClientElseThrowInvalidClient(pushedAuthorizationRequestAuthentication); + RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext = OAuth2AuthorizationCodeRequestAuthenticationContext + .with(toAuthorizationCodeRequestAuthentication(pushedAuthorizationRequestAuthentication)) + .registeredClient(registeredClient) + .build(); + + // grant_type + OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_AUTHORIZATION_GRANT_TYPE_VALIDATOR + .accept(authenticationContext); + + // redirect_uri and scope + this.authenticationValidator.accept(authenticationContext); + + // code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE) + OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_CODE_CHALLENGE_VALIDATOR + .accept(authenticationContext); + + // prompt (OPTIONAL for OpenID Connect 1.0 Authentication Request) + OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_PROMPT_VALIDATOR.accept(authenticationContext); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated pushed authorization request parameters"); + } + + // @formatter:off + OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(pushedAuthorizationRequestAuthentication.getAuthorizationUri()) + .clientId(registeredClient.getClientId()) + .redirectUri(pushedAuthorizationRequestAuthentication.getRedirectUri()) + .scopes(pushedAuthorizationRequestAuthentication.getScopes()) + .state(pushedAuthorizationRequestAuthentication.getState()) + .additionalParameters(pushedAuthorizationRequestAuthentication.getAdditionalParameters()) + .build(); + // @formatter:on + + OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = OAuth2PushedAuthorizationRequestUri + .create(); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Generated pushed authorization request uri"); + } + + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(clientPrincipal.getName()) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest) + .attribute(OAuth2ParameterNames.STATE, pushedAuthorizationRequestUri.getState()) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated pushed authorization request"); + } + + return new OAuth2PushedAuthorizationRequestAuthenticationToken(authorizationRequest.getAuthorizationUri(), + authorizationRequest.getClientId(), clientPrincipal, pushedAuthorizationRequestUri.getRequestUri(), + pushedAuthorizationRequestUri.getExpiresAt(), authorizationRequest.getRedirectUri(), + authorizationRequest.getState(), authorizationRequest.getScopes()); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2PushedAuthorizationRequestAuthenticationToken.class.isAssignableFrom(authentication); + } + + /** + * Sets the {@code Consumer} providing access to the + * {@link OAuth2AuthorizationCodeRequestAuthenticationContext} and is responsible for + * validating specific OAuth 2.0 Pushed Authorization Request parameters associated in + * the {@link OAuth2AuthorizationCodeRequestAuthenticationToken}. The default + * authentication validator is + * {@link OAuth2AuthorizationCodeRequestAuthenticationValidator}. + * + *

+ * NOTE: The authentication validator MUST throw + * {@link OAuth2AuthorizationCodeRequestAuthenticationException} if validation fails. + * @param authenticationValidator the {@code Consumer} providing access to the + * {@link OAuth2AuthorizationCodeRequestAuthenticationContext} and is responsible for + * validating specific OAuth 2.0 Pushed Authorization Request parameters + */ + public void setAuthenticationValidator( + Consumer authenticationValidator) { + Assert.notNull(authenticationValidator, "authenticationValidator cannot be null"); + this.authenticationValidator = authenticationValidator; + } + + private static OAuth2AuthorizationCodeRequestAuthenticationToken toAuthorizationCodeRequestAuthentication( + OAuth2PushedAuthorizationRequestAuthenticationToken pushedAuthorizationCodeRequestAuthentication) { + return new OAuth2AuthorizationCodeRequestAuthenticationToken( + pushedAuthorizationCodeRequestAuthentication.getAuthorizationUri(), + pushedAuthorizationCodeRequestAuthentication.getClientId(), + (Authentication) pushedAuthorizationCodeRequestAuthentication.getPrincipal(), + pushedAuthorizationCodeRequestAuthentication.getRedirectUri(), + pushedAuthorizationCodeRequestAuthentication.getState(), + pushedAuthorizationCodeRequestAuthentication.getScopes(), + pushedAuthorizationCodeRequestAuthentication.getAdditionalParameters()); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationToken.java new file mode 100644 index 000000000..91547633b --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationToken.java @@ -0,0 +1,94 @@ +/* + * Copyright 2020-2025 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.util.Map; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation for the OAuth 2.0 Pushed Authorization Request + * used in the Authorization Code Grant. + * + * @author Joe Grandja + * @since 1.5 + * @see OAuth2PushedAuthorizationRequestAuthenticationProvider + */ +public class OAuth2PushedAuthorizationRequestAuthenticationToken + extends AbstractOAuth2AuthorizationCodeRequestAuthenticationToken { + + private final String requestUri; + + private final Instant requestUriExpiresAt; + + /** + * Constructs an {@code OAuth2PushedAuthorizationRequestAuthenticationToken} using the + * provided parameters. + * @param authorizationUri the authorization URI + * @param clientId the client identifier + * @param principal the authenticated client principal + * @param redirectUri the redirect uri + * @param state the state + * @param scopes the requested scope(s) + * @param additionalParameters the additional parameters + */ + public OAuth2PushedAuthorizationRequestAuthenticationToken(String authorizationUri, String clientId, + Authentication principal, @Nullable String redirectUri, @Nullable String state, + @Nullable Set scopes, @Nullable Map additionalParameters) { + super(authorizationUri, clientId, principal, redirectUri, state, scopes, additionalParameters); + this.requestUri = null; + this.requestUriExpiresAt = null; + } + + /** + * Constructs an {@code OAuth2PushedAuthorizationRequestAuthenticationToken} using the + * provided parameters. + * @param authorizationUri the authorization URI + * @param clientId the client identifier + * @param principal the authenticated client principal + * @param requestUri the request URI corresponding to the authorization request posted + * @param requestUriExpiresAt the expiration time on or after which the + * {@code requestUri} MUST NOT be accepted + * @param redirectUri the redirect uri + * @param state the state + * @param scopes the authorized scope(s) + */ + public OAuth2PushedAuthorizationRequestAuthenticationToken(String authorizationUri, String clientId, + Authentication principal, String requestUri, Instant requestUriExpiresAt, @Nullable String redirectUri, + @Nullable String state, @Nullable Set scopes) { + super(authorizationUri, clientId, principal, redirectUri, state, scopes, null); + Assert.hasText(requestUri, "requestUri cannot be empty"); + Assert.notNull(requestUriExpiresAt, "requestUriExpiresAt cannot be null"); + this.requestUri = requestUri; + this.requestUriExpiresAt = requestUriExpiresAt; + setAuthenticated(true); + } + + @Nullable + public String getRequestUri() { + return this.requestUri; + } + + @Nullable + public Instant getRequestUriExpiresAt() { + return this.requestUriExpiresAt; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestUri.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestUri.java new file mode 100644 index 000000000..538cc8c5a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestUri.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020-2025 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.util.Base64; + +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.StringKeyGenerator; + +/** + * @author Joe Grandja + * @since 1.5 + */ +final class OAuth2PushedAuthorizationRequestUri { + + private static final String REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; + + private static final String REQUEST_URI_DELIMITER = "___"; + + private static final StringKeyGenerator DEFAULT_STATE_GENERATOR = new Base64StringKeyGenerator( + Base64.getUrlEncoder()); + + private String requestUri; + + private String state; + + private Instant expiresAt; + + static OAuth2PushedAuthorizationRequestUri create() { + String state = DEFAULT_STATE_GENERATOR.generateKey(); + Instant expiresAt = Instant.now().plusSeconds(30); + OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = new OAuth2PushedAuthorizationRequestUri(); + pushedAuthorizationRequestUri.requestUri = REQUEST_URI_PREFIX + state + REQUEST_URI_DELIMITER + + expiresAt.toEpochMilli(); + pushedAuthorizationRequestUri.state = state + REQUEST_URI_DELIMITER + expiresAt.toEpochMilli(); + pushedAuthorizationRequestUri.expiresAt = expiresAt; + return pushedAuthorizationRequestUri; + } + + static OAuth2PushedAuthorizationRequestUri parse(String requestUri) { + int stateStartIndex = REQUEST_URI_PREFIX.length(); + int expiresAtStartIndex = requestUri.indexOf(REQUEST_URI_DELIMITER) + REQUEST_URI_DELIMITER.length(); + OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = new OAuth2PushedAuthorizationRequestUri(); + pushedAuthorizationRequestUri.requestUri = requestUri; + pushedAuthorizationRequestUri.state = requestUri.substring(stateStartIndex); + pushedAuthorizationRequestUri.expiresAt = Instant + .ofEpochMilli(Long.parseLong(requestUri.substring(expiresAtStartIndex))); + return pushedAuthorizationRequestUri; + } + + String getRequestUri() { + return this.requestUri; + } + + String getState() { + return this.state; + } + + Instant getExpiresAt() { + return this.expiresAt; + } + + private OAuth2PushedAuthorizationRequestUri() { + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcPrompt.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcPrompt.java new file mode 100644 index 000000000..2af3b9d2b --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcPrompt.java @@ -0,0 +1,38 @@ +/* + * Copyright 2020-2025 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; + +/** + * The values defined for the "prompt" parameter for the OpenID Connect 1.0 Authentication + * Request. + * + * @author Joe Grandja + * @since 1.5 + */ +final class OidcPrompt { + + static final String NONE = "none"; + + static final String LOGIN = "login"; + + static final String CONSENT = "consent"; + + static final String SELECT_ACCOUNT = "select_account"; + + private OidcPrompt() { + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java index 73d4b224a..094fc5813 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-2025 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. @@ -20,6 +20,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.nimbusds.jose.jwk.source.JWKSource; @@ -42,6 +43,7 @@ import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; @@ -69,6 +71,7 @@ * @see OAuth2ClientAuthenticationConfigurer * @see OAuth2AuthorizationServerMetadataEndpointConfigurer * @see OAuth2AuthorizationEndpointConfigurer + * @see OAuth2PushedAuthorizationRequestEndpointConfigurer * @see OAuth2TokenEndpointConfigurer * @see OAuth2TokenIntrospectionEndpointConfigurer * @see OAuth2TokenRevocationEndpointConfigurer @@ -196,6 +199,27 @@ public OAuth2AuthorizationServerConfigurer authorizationEndpoint( return this; } + /** + * Configures the OAuth 2.0 Pushed Authorization Request Endpoint. + * @param pushedAuthorizationRequestEndpointCustomizer the {@link Customizer} + * providing access to the {@link OAuth2PushedAuthorizationRequestEndpointConfigurer} + * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration + * @since 1.5 + */ + public OAuth2AuthorizationServerConfigurer pushedAuthorizationRequestEndpoint( + Customizer pushedAuthorizationRequestEndpointCustomizer) { + OAuth2PushedAuthorizationRequestEndpointConfigurer pushedAuthorizationRequestEndpointConfigurer = getConfigurer( + OAuth2PushedAuthorizationRequestEndpointConfigurer.class); + if (pushedAuthorizationRequestEndpointConfigurer == null) { + addConfigurer(OAuth2PushedAuthorizationRequestEndpointConfigurer.class, + new OAuth2PushedAuthorizationRequestEndpointConfigurer(this::postProcess)); + pushedAuthorizationRequestEndpointConfigurer = getConfigurer( + OAuth2PushedAuthorizationRequestEndpointConfigurer.class); + } + pushedAuthorizationRequestEndpointCustomizer.customize(pushedAuthorizationRequestEndpointConfigurer); + return this; + } + /** * Configures the OAuth 2.0 Token Endpoint. * @param tokenEndpointCustomizer the {@link Customizer} providing access to the @@ -314,20 +338,28 @@ public void init(HttpSecurity httpSecurity) throws Exception { else { // OpenID Connect is disabled. // Add an authentication validator that rejects authentication requests. + Consumer oidcAuthenticationRequestValidator = ( + authenticationContext) -> { + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationContext + .getAuthentication(); + if (authorizationCodeRequestAuthentication.getScopes().contains(OidcScopes.OPENID)) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, + "OpenID Connect 1.0 authentication requests are restricted.", + "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"); + throw new OAuth2AuthorizationCodeRequestAuthenticationException(error, + authorizationCodeRequestAuthentication); + } + }; OAuth2AuthorizationEndpointConfigurer authorizationEndpointConfigurer = getConfigurer( OAuth2AuthorizationEndpointConfigurer.class); authorizationEndpointConfigurer - .addAuthorizationCodeRequestAuthenticationValidator((authenticationContext) -> { - OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationContext - .getAuthentication(); - if (authorizationCodeRequestAuthentication.getScopes().contains(OidcScopes.OPENID)) { - OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE, - "OpenID Connect 1.0 authentication requests are restricted.", - "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"); - throw new OAuth2AuthorizationCodeRequestAuthenticationException(error, - authorizationCodeRequestAuthentication); - } - }); + .addAuthorizationCodeRequestAuthenticationValidator(oidcAuthenticationRequestValidator); + OAuth2PushedAuthorizationRequestEndpointConfigurer pushedAuthorizationRequestEndpointConfigurer = getConfigurer( + OAuth2PushedAuthorizationRequestEndpointConfigurer.class); + if (pushedAuthorizationRequestEndpointConfigurer != null) { + pushedAuthorizationRequestEndpointConfigurer + .addAuthorizationCodeRequestAuthenticationValidator(oidcAuthenticationRequestValidator); + } } List requestMatchers = new ArrayList<>(); @@ -344,11 +376,18 @@ public void init(HttpSecurity httpSecurity) throws Exception { ExceptionHandlingConfigurer exceptionHandling = httpSecurity .getConfigurer(ExceptionHandlingConfigurer.class); if (exceptionHandling != null) { + List preferredMatchers = new ArrayList<>(); + preferredMatchers.add(getRequestMatcher(OAuth2TokenEndpointConfigurer.class)); + preferredMatchers.add(getRequestMatcher(OAuth2TokenIntrospectionEndpointConfigurer.class)); + preferredMatchers.add(getRequestMatcher(OAuth2TokenRevocationEndpointConfigurer.class)); + preferredMatchers.add(getRequestMatcher(OAuth2DeviceAuthorizationEndpointConfigurer.class)); + RequestMatcher preferredMatcher = getRequestMatcher( + OAuth2PushedAuthorizationRequestEndpointConfigurer.class); + if (preferredMatcher != null) { + preferredMatchers.add(preferredMatcher); + } exceptionHandling.defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), - new OrRequestMatcher(getRequestMatcher(OAuth2TokenEndpointConfigurer.class), - getRequestMatcher(OAuth2TokenIntrospectionEndpointConfigurer.class), - getRequestMatcher(OAuth2TokenRevocationEndpointConfigurer.class), - getRequestMatcher(OAuth2DeviceAuthorizationEndpointConfigurer.class))); + new OrRequestMatcher(preferredMatchers)); } httpSecurity.csrf((csrf) -> csrf.ignoringRequestMatchers(this.endpointsMatcher)); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java index 44b252256..bf7f38d36 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-2025 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. @@ -196,10 +196,15 @@ void init(HttpSecurity httpSecurity) { ? OAuth2ConfigurerUtils .withMultipleIssuersPattern(authorizationServerSettings.getDeviceAuthorizationEndpoint()) : authorizationServerSettings.getDeviceAuthorizationEndpoint(); + String pushedAuthorizationRequestEndpointUri = authorizationServerSettings.isMultipleIssuersAllowed() + ? OAuth2ConfigurerUtils + .withMultipleIssuersPattern(authorizationServerSettings.getPushedAuthorizationRequestEndpoint()) + : authorizationServerSettings.getPushedAuthorizationRequestEndpoint(); this.requestMatcher = new OrRequestMatcher(new AntPathRequestMatcher(tokenEndpointUri, HttpMethod.POST.name()), new AntPathRequestMatcher(tokenIntrospectionEndpointUri, HttpMethod.POST.name()), new AntPathRequestMatcher(tokenRevocationEndpointUri, HttpMethod.POST.name()), - new AntPathRequestMatcher(deviceAuthorizationEndpointUri, HttpMethod.POST.name())); + new AntPathRequestMatcher(deviceAuthorizationEndpointUri, HttpMethod.POST.name()), + new AntPathRequestMatcher(pushedAuthorizationRequestEndpointUri, HttpMethod.POST.name())); List authenticationProviders = createDefaultAuthenticationProviders(httpSecurity); if (!this.authenticationProviders.isEmpty()) { authenticationProviders.addAll(0, this.authenticationProviders); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2PushedAuthorizationRequestEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2PushedAuthorizationRequestEndpointConfigurer.java new file mode 100644 index 000000000..e51b5f675 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2PushedAuthorizationRequestEndpointConfigurer.java @@ -0,0 +1,265 @@ +/* + * Copyright 2020-2025 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.config.annotation.web.configurers; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationValidator; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2PushedAuthorizationRequestAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2PushedAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.web.OAuth2PushedAuthorizationRequestEndpointFilter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeRequestAuthenticationConverter; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.DelegatingAuthenticationConverter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +/** + * Configurer for the OAuth 2.0 Pushed Authorization Request Endpoint. + * + * @author Joe Grandja + * @since 1.5 + * @see OAuth2AuthorizationServerConfigurer#pushedAuthorizationRequestEndpoint + * @see OAuth2PushedAuthorizationRequestEndpointFilter + */ +public final class OAuth2PushedAuthorizationRequestEndpointConfigurer extends AbstractOAuth2Configurer { + + private RequestMatcher requestMatcher; + + private final List pushedAuthorizationRequestConverters = new ArrayList<>(); + + private Consumer> pushedAuthorizationRequestConvertersConsumer = ( + authorizationRequestConverters) -> { + }; + + private final List authenticationProviders = new ArrayList<>(); + + private Consumer> authenticationProvidersConsumer = (authenticationProviders) -> { + }; + + private AuthenticationSuccessHandler pushedAuthorizationResponseHandler; + + private AuthenticationFailureHandler errorResponseHandler; + + private Consumer authorizationCodeRequestAuthenticationValidator; + + /** + * Restrict for internal use only. + * @param objectPostProcessor an {@code ObjectPostProcessor} + */ + OAuth2PushedAuthorizationRequestEndpointConfigurer(ObjectPostProcessor objectPostProcessor) { + super(objectPostProcessor); + } + + /** + * Adds an {@link AuthenticationConverter} used when attempting to extract a Pushed + * Authorization Request from {@link HttpServletRequest} to an instance of + * {@link OAuth2PushedAuthorizationRequestAuthenticationToken} used for authenticating + * the request. + * @param pushedAuthorizationRequestConverter an {@link AuthenticationConverter} used + * when attempting to extract a Pushed Authorization Request from + * {@link HttpServletRequest} + * @return the {@link OAuth2PushedAuthorizationRequestEndpointConfigurer} for further + * configuration + */ + public OAuth2PushedAuthorizationRequestEndpointConfigurer pushedAuthorizationRequestConverter( + AuthenticationConverter pushedAuthorizationRequestConverter) { + Assert.notNull(pushedAuthorizationRequestConverter, "pushedAuthorizationRequestConverter cannot be null"); + this.pushedAuthorizationRequestConverters.add(pushedAuthorizationRequestConverter); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default and + * (optionally) added + * {@link #pushedAuthorizationRequestConverter(AuthenticationConverter) + * AuthenticationConverter}'s allowing the ability to add, remove, or customize a + * specific {@link AuthenticationConverter}. + * @param pushedAuthorizationRequestConvertersConsumer the {@code Consumer} providing + * access to the {@code List} of default and (optionally) added + * {@link AuthenticationConverter}'s + * @return the {@link OAuth2PushedAuthorizationRequestEndpointConfigurer} for further + * configuration + */ + public OAuth2PushedAuthorizationRequestEndpointConfigurer pushedAuthorizationRequestConverters( + Consumer> pushedAuthorizationRequestConvertersConsumer) { + Assert.notNull(pushedAuthorizationRequestConvertersConsumer, + "pushedAuthorizationRequestConvertersConsumer cannot be null"); + this.pushedAuthorizationRequestConvertersConsumer = pushedAuthorizationRequestConvertersConsumer; + return this; + } + + /** + * Adds an {@link AuthenticationProvider} used for authenticating an + * {@link OAuth2PushedAuthorizationRequestAuthenticationToken}. + * @param authenticationProvider an {@link AuthenticationProvider} used for + * authenticating an {@link OAuth2PushedAuthorizationRequestAuthenticationToken} + * @return the {@link OAuth2PushedAuthorizationRequestEndpointConfigurer} for further + * configuration + */ + public OAuth2PushedAuthorizationRequestEndpointConfigurer authenticationProvider( + AuthenticationProvider authenticationProvider) { + Assert.notNull(authenticationProvider, "authenticationProvider cannot be null"); + this.authenticationProviders.add(authenticationProvider); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default and + * (optionally) added {@link #authenticationProvider(AuthenticationProvider) + * AuthenticationProvider}'s allowing the ability to add, remove, or customize a + * specific {@link AuthenticationProvider}. + * @param authenticationProvidersConsumer the {@code Consumer} providing access to the + * {@code List} of default and (optionally) added {@link AuthenticationProvider}'s + * @return the {@link OAuth2PushedAuthorizationRequestEndpointConfigurer} for further + * configuration + */ + public OAuth2PushedAuthorizationRequestEndpointConfigurer authenticationProviders( + Consumer> authenticationProvidersConsumer) { + Assert.notNull(authenticationProvidersConsumer, "authenticationProvidersConsumer cannot be null"); + this.authenticationProvidersConsumer = authenticationProvidersConsumer; + return this; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an + * {@link OAuth2PushedAuthorizationRequestAuthenticationToken} and returning the + * Pushed Authorization Response. + * @param pushedAuthorizationResponseHandler the {@link AuthenticationSuccessHandler} + * used for handling an {@link OAuth2PushedAuthorizationRequestAuthenticationToken} + * @return the {@link OAuth2PushedAuthorizationRequestEndpointConfigurer} for further + * configuration + */ + public OAuth2PushedAuthorizationRequestEndpointConfigurer pushedAuthorizationResponseHandler( + AuthenticationSuccessHandler pushedAuthorizationResponseHandler) { + this.pushedAuthorizationResponseHandler = pushedAuthorizationResponseHandler; + return this; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an + * {@link OAuth2AuthorizationCodeRequestAuthenticationException} and returning the + * {@link OAuth2Error Error Response}. + * @param errorResponseHandler the {@link AuthenticationFailureHandler} used for + * handling an {@link OAuth2AuthorizationCodeRequestAuthenticationException} + * @return the {@link OAuth2PushedAuthorizationRequestEndpointConfigurer} for further + * configuration + */ + public OAuth2PushedAuthorizationRequestEndpointConfigurer errorResponseHandler( + AuthenticationFailureHandler errorResponseHandler) { + this.errorResponseHandler = errorResponseHandler; + return this; + } + + void addAuthorizationCodeRequestAuthenticationValidator( + Consumer authenticationValidator) { + this.authorizationCodeRequestAuthenticationValidator = (this.authorizationCodeRequestAuthenticationValidator == null) + ? authenticationValidator + : this.authorizationCodeRequestAuthenticationValidator.andThen(authenticationValidator); + } + + @Override + void init(HttpSecurity httpSecurity) { + AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils + .getAuthorizationServerSettings(httpSecurity); + String pushedAuthorizationRequestEndpointUri = authorizationServerSettings.isMultipleIssuersAllowed() + ? OAuth2ConfigurerUtils + .withMultipleIssuersPattern(authorizationServerSettings.getPushedAuthorizationRequestEndpoint()) + : authorizationServerSettings.getPushedAuthorizationRequestEndpoint(); + this.requestMatcher = new AntPathRequestMatcher(pushedAuthorizationRequestEndpointUri, HttpMethod.POST.name()); + List authenticationProviders = createDefaultAuthenticationProviders(httpSecurity); + if (!this.authenticationProviders.isEmpty()) { + authenticationProviders.addAll(0, this.authenticationProviders); + } + this.authenticationProvidersConsumer.accept(authenticationProviders); + authenticationProviders.forEach( + (authenticationProvider) -> httpSecurity.authenticationProvider(postProcess(authenticationProvider))); + } + + @Override + void configure(HttpSecurity httpSecurity) { + AuthenticationManager authenticationManager = httpSecurity.getSharedObject(AuthenticationManager.class); + AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils + .getAuthorizationServerSettings(httpSecurity); + String pushedAuthorizationRequestEndpointUri = authorizationServerSettings.isMultipleIssuersAllowed() + ? OAuth2ConfigurerUtils + .withMultipleIssuersPattern(authorizationServerSettings.getPushedAuthorizationRequestEndpoint()) + : authorizationServerSettings.getPushedAuthorizationRequestEndpoint(); + OAuth2PushedAuthorizationRequestEndpointFilter pushedAuthorizationRequestEndpointFilter = new OAuth2PushedAuthorizationRequestEndpointFilter( + authenticationManager, pushedAuthorizationRequestEndpointUri); + List authenticationConverters = createDefaultAuthenticationConverters(); + if (!this.pushedAuthorizationRequestConverters.isEmpty()) { + authenticationConverters.addAll(0, this.pushedAuthorizationRequestConverters); + } + this.pushedAuthorizationRequestConvertersConsumer.accept(authenticationConverters); + pushedAuthorizationRequestEndpointFilter + .setAuthenticationConverter(new DelegatingAuthenticationConverter(authenticationConverters)); + if (this.pushedAuthorizationResponseHandler != null) { + pushedAuthorizationRequestEndpointFilter + .setAuthenticationSuccessHandler(this.pushedAuthorizationResponseHandler); + } + if (this.errorResponseHandler != null) { + pushedAuthorizationRequestEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler); + } + httpSecurity.addFilterAfter(postProcess(pushedAuthorizationRequestEndpointFilter), AuthorizationFilter.class); + } + + @Override + RequestMatcher getRequestMatcher() { + return this.requestMatcher; + } + + private static List createDefaultAuthenticationConverters() { + List authenticationConverters = new ArrayList<>(); + + authenticationConverters.add(new OAuth2AuthorizationCodeRequestAuthenticationConverter()); + + return authenticationConverters; + } + + private List createDefaultAuthenticationProviders(HttpSecurity httpSecurity) { + List authenticationProviders = new ArrayList<>(); + + OAuth2PushedAuthorizationRequestAuthenticationProvider pushedAuthorizationRequestAuthenticationProvider = new OAuth2PushedAuthorizationRequestAuthenticationProvider( + OAuth2ConfigurerUtils.getAuthorizationService(httpSecurity)); + if (this.authorizationCodeRequestAuthenticationValidator != null) { + pushedAuthorizationRequestAuthenticationProvider + .setAuthenticationValidator(new OAuth2AuthorizationCodeRequestAuthenticationValidator() + .andThen(this.authorizationCodeRequestAuthenticationValidator)); + } + authenticationProviders.add(pushedAuthorizationRequestAuthenticationProvider); + + return authenticationProviders; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java index 26d7df8de..418554b58 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-2025 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. @@ -72,6 +72,16 @@ public String getAuthorizationEndpoint() { return getSetting(ConfigurationSettingNames.AuthorizationServer.AUTHORIZATION_ENDPOINT); } + /** + * Returns the OAuth 2.0 Pushed Authorization Request endpoint. The default is + * {@code /oauth2/par}. + * @return the Pushed Authorization Request endpoint + * @since 1.5 + */ + public String getPushedAuthorizationRequestEndpoint() { + return getSetting(ConfigurationSettingNames.AuthorizationServer.PUSHED_AUTHORIZATION_REQUEST_ENDPOINT); + } + /** * Returns the OAuth 2.0 Device Authorization endpoint. The default is * {@code /oauth2/device_authorization}. @@ -160,6 +170,7 @@ public String getOidcLogoutEndpoint() { public static Builder builder() { return new Builder().multipleIssuersAllowed(false) .authorizationEndpoint("/oauth2/authorize") + .pushedAuthorizationRequestEndpoint("/oauth2/par") .deviceAuthorizationEndpoint("/oauth2/device_authorization") .deviceVerificationEndpoint("/oauth2/device_verification") .tokenEndpoint("/oauth2/token") @@ -236,6 +247,18 @@ public Builder authorizationEndpoint(String authorizationEndpoint) { return setting(ConfigurationSettingNames.AuthorizationServer.AUTHORIZATION_ENDPOINT, authorizationEndpoint); } + /** + * Sets the OAuth 2.0 Pushed Authorization Request endpoint. + * @param pushedAuthorizationRequestEndpoint the Pushed Authorization Request + * endpoint + * @return the {@link Builder} for further configuration + * @since 1.5 + */ + public Builder pushedAuthorizationRequestEndpoint(String pushedAuthorizationRequestEndpoint) { + return setting(ConfigurationSettingNames.AuthorizationServer.PUSHED_AUTHORIZATION_REQUEST_ENDPOINT, + pushedAuthorizationRequestEndpoint); + } + /** * Sets the OAuth 2.0 Device Authorization endpoint. * @param deviceAuthorizationEndpoint the Device Authorization endpoint diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java index 9e7f5ca90..96edd2a54 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-2025 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. @@ -112,6 +112,13 @@ public static final class AuthorizationServer { public static final String AUTHORIZATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE .concat("authorization-endpoint"); + /** + * Set the OAuth 2.0 Pushed Authorization Request endpoint. + * @since 1.5 + */ + public static final String PUSHED_AUTHORIZATION_REQUEST_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE + .concat("pushed-authorization-request-endpoint"); + /** * Set the OAuth 2.0 Device Authorization endpoint. */ diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/HttpMessageConverters.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/HttpMessageConverters.java new file mode 100644 index 000000000..a2c687afe --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/HttpMessageConverters.java @@ -0,0 +1,63 @@ +/* + * Copyright 2020-2025 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.web; + +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.GsonHttpMessageConverter; +import org.springframework.http.converter.json.JsonbHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.util.ClassUtils; + +/** + * Utility methods for {@link HttpMessageConverter}'s. + * + * @author Joe Grandja + * @since 1.5 + */ +final class HttpMessageConverters { + + private static final boolean jackson2Present; + + private static final boolean gsonPresent; + + private static final boolean jsonbPresent; + + static { + ClassLoader classLoader = HttpMessageConverters.class.getClassLoader(); + jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) + && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); + gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); + jsonbPresent = ClassUtils.isPresent("jakarta.json.bind.Jsonb", classLoader); + } + + private HttpMessageConverters() { + } + + static GenericHttpMessageConverter getJsonMessageConverter() { + if (jackson2Present) { + return new MappingJackson2HttpMessageConverter(); + } + if (gsonPresent) { + return new GsonHttpMessageConverter(); + } + if (jsonbPresent) { + return new JsonbHttpMessageConverter(); + } + return null; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2PushedAuthorizationRequestEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2PushedAuthorizationRequestEndpointFilter.java new file mode 100644 index 000000000..da1491279 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2PushedAuthorizationRequestEndpointFilter.java @@ -0,0 +1,223 @@ +/* + * Copyright 2020-2025 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.web; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Map; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.log.LogMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2PushedAuthorizationRequestAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2PushedAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeRequestAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ErrorAuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * A {@code Filter} for the OAuth 2.0 Pushed Authorization Request endpoint, which handles + * the processing of the OAuth 2.0 Pushed Authorization Request. + * + * @author Joe Grandja + * @since 1.5 + * @see AuthenticationManager + * @see OAuth2PushedAuthorizationRequestAuthenticationProvider + * @see Section + * 2. Pushed Authorization Request Endpoint + * @see Section 2.1 Pushed + * Authorization Request + * @see Section 2.2 Pushed + * Authorization Response + */ +public final class OAuth2PushedAuthorizationRequestEndpointFilter extends OncePerRequestFilter { + + /** + * The default endpoint {@code URI} for pushed authorization requests. + */ + private static final String DEFAULT_PUSHED_AUTHORIZATION_REQUEST_ENDPOINT_URI = "/oauth2/par"; + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { + }; + + private static final GenericHttpMessageConverter JSON_MESSAGE_CONVERTER = HttpMessageConverters + .getJsonMessageConverter(); + + private final AuthenticationManager authenticationManager; + + private final RequestMatcher pushedAuthorizationRequestEndpointMatcher; + + private AuthenticationDetailsSource authenticationDetailsSource = new WebAuthenticationDetailsSource(); + + private AuthenticationConverter authenticationConverter; + + private AuthenticationSuccessHandler authenticationSuccessHandler = this::sendPushedAuthorizationResponse; + + private AuthenticationFailureHandler authenticationFailureHandler = new OAuth2ErrorAuthenticationFailureHandler(); + + /** + * Constructs an {@code OAuth2PushedAuthorizationRequestEndpointFilter} using the + * provided parameters. + * @param authenticationManager the authentication manager + */ + public OAuth2PushedAuthorizationRequestEndpointFilter(AuthenticationManager authenticationManager) { + this(authenticationManager, DEFAULT_PUSHED_AUTHORIZATION_REQUEST_ENDPOINT_URI); + } + + /** + * Constructs an {@code OAuth2PushedAuthorizationRequestEndpointFilter} using the + * provided parameters. + * @param authenticationManager the authentication manager + * @param pushedAuthorizationRequestEndpointUri the endpoint {@code URI} for pushed + * authorization requests + */ + public OAuth2PushedAuthorizationRequestEndpointFilter(AuthenticationManager authenticationManager, + String pushedAuthorizationRequestEndpointUri) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.hasText(pushedAuthorizationRequestEndpointUri, "pushedAuthorizationRequestEndpointUri cannot be empty"); + this.authenticationManager = authenticationManager; + this.pushedAuthorizationRequestEndpointMatcher = new AntPathRequestMatcher( + pushedAuthorizationRequestEndpointUri, HttpMethod.POST.name()); + this.authenticationConverter = new OAuth2AuthorizationCodeRequestAuthenticationConverter(); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!this.pushedAuthorizationRequestEndpointMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + try { + Authentication pushedAuthorizationRequestAuthentication = this.authenticationConverter.convert(request); + if (pushedAuthorizationRequestAuthentication instanceof AbstractAuthenticationToken) { + ((AbstractAuthenticationToken) pushedAuthorizationRequestAuthentication) + .setDetails(this.authenticationDetailsSource.buildDetails(request)); + } + Authentication pushedAuthorizationRequestAuthenticationResult = this.authenticationManager + .authenticate(pushedAuthorizationRequestAuthentication); + + this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, + pushedAuthorizationRequestAuthenticationResult); + + } + catch (OAuth2AuthenticationException ex) { + if (this.logger.isTraceEnabled()) { + this.logger.trace(LogMessage.format("Pushed authorization request failed: %s", ex.getError()), ex); + } + this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex); + } + } + + /** + * Sets the {@link AuthenticationDetailsSource} used for building an authentication + * details instance from {@link HttpServletRequest}. + * @param authenticationDetailsSource the {@link AuthenticationDetailsSource} used for + * building an authentication details instance from {@link HttpServletRequest} + */ + public void setAuthenticationDetailsSource( + AuthenticationDetailsSource authenticationDetailsSource) { + Assert.notNull(authenticationDetailsSource, "authenticationDetailsSource cannot be null"); + this.authenticationDetailsSource = authenticationDetailsSource; + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract a Pushed + * Authorization Request from {@link HttpServletRequest} to an instance of + * {@link OAuth2PushedAuthorizationRequestAuthenticationToken} used for authenticating + * the request. + * @param authenticationConverter the {@link AuthenticationConverter} used when + * attempting to extract a Pushed Authorization Request from + * {@link HttpServletRequest} + */ + public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an + * {@link OAuth2PushedAuthorizationRequestAuthenticationToken} and returning the + * Pushed Authorization Response. + * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler} used + * for handling an {@link OAuth2PushedAuthorizationRequestAuthenticationToken} + */ + public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) { + Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); + this.authenticationSuccessHandler = authenticationSuccessHandler; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an + * {@link OAuth2AuthenticationException} and returning the {@link OAuth2Error Error + * Response}. + * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used + * for handling an {@link OAuth2AuthenticationException} + */ + public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { + Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null"); + this.authenticationFailureHandler = authenticationFailureHandler; + } + + private void sendPushedAuthorizationResponse(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + OAuth2PushedAuthorizationRequestAuthenticationToken pushedAuthorizationRequestAuthentication = (OAuth2PushedAuthorizationRequestAuthenticationToken) authentication; + + Map pushedAuthorizationResponse = new LinkedHashMap<>(); + pushedAuthorizationResponse.put("request_uri", pushedAuthorizationRequestAuthentication.getRequestUri()); + long expiresIn = ChronoUnit.SECONDS.between(Instant.now(), + pushedAuthorizationRequestAuthentication.getRequestUriExpiresAt()); + pushedAuthorizationResponse.put(OAuth2ParameterNames.EXPIRES_IN, expiresIn); + + ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + httpResponse.setStatusCode(HttpStatus.CREATED); + + JSON_MESSAGE_CONVERTER.write(pushedAuthorizationResponse, STRING_OBJECT_MAP.getType(), + MediaType.APPLICATION_JSON, httpResponse); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeRequestAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeRequestAuthenticationConverter.java index 9443fe145..357329f15 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeRequestAuthenticationConverter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeRequestAuthenticationConverter.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -35,7 +36,12 @@ import org.springframework.security.oauth2.core.oidc.OidcScopes; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2PushedAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter; +import org.springframework.security.oauth2.server.authorization.web.OAuth2PushedAuthorizationRequestEndpointFilter; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.util.matcher.AndRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; @@ -47,14 +53,17 @@ /** * Attempts to extract an Authorization Request from {@link HttpServletRequest} for the * OAuth 2.0 Authorization Code Grant and then converts it to an - * {@link OAuth2AuthorizationCodeRequestAuthenticationToken} used for authenticating the + * {@link OAuth2AuthorizationCodeRequestAuthenticationToken} OR + * {@link OAuth2PushedAuthorizationRequestAuthenticationToken} used for authenticating the * request. * * @author Joe Grandja * @since 0.1.2 * @see AuthenticationConverter * @see OAuth2AuthorizationCodeRequestAuthenticationToken + * @see OAuth2PushedAuthorizationRequestAuthenticationToken * @see OAuth2AuthorizationEndpointFilter + * @see OAuth2PushedAuthorizationRequestEndpointFilter */ public final class OAuth2AuthorizationCodeRequestAuthenticationConverter implements AuthenticationConverter { @@ -76,13 +85,30 @@ public Authentication convert(HttpServletRequest request) { MultiValueMap parameters = "GET".equals(request.getMethod()) ? OAuth2EndpointUtils.getQueryParameters(request) : OAuth2EndpointUtils.getFormParameters(request); - // response_type (REQUIRED) - String responseType = parameters.getFirst(OAuth2ParameterNames.RESPONSE_TYPE); - if (!StringUtils.hasText(responseType) || parameters.get(OAuth2ParameterNames.RESPONSE_TYPE).size() != 1) { - throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.RESPONSE_TYPE); + boolean pushedAuthorizationRequest = isPushedAuthorizationRequest(request); + + // request_uri (OPTIONAL) - provided if an authorization request was previously + // pushed (RFC 9126 OAuth 2.0 Pushed Authorization Requests) + String requestUri = parameters.getFirst("request_uri"); + if (StringUtils.hasText(requestUri)) { + if (pushedAuthorizationRequest) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, "request_uri"); + } + else if (parameters.get("request_uri").size() != 1) { + // Authorization Request + throwError(OAuth2ErrorCodes.INVALID_REQUEST, "request_uri"); + } } - else if (!responseType.equals(OAuth2AuthorizationResponseType.CODE.getValue())) { - throwError(OAuth2ErrorCodes.UNSUPPORTED_RESPONSE_TYPE, OAuth2ParameterNames.RESPONSE_TYPE); + + if (!StringUtils.hasText(requestUri)) { + // response_type (REQUIRED) + String responseType = parameters.getFirst(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); + } } String authorizationUri = request.getRequestURL().toString(); @@ -150,8 +176,24 @@ else if (!responseType.equals(OAuth2AuthorizationResponseType.CODE.getValue())) } }); - return new OAuth2AuthorizationCodeRequestAuthenticationToken(authorizationUri, clientId, principal, redirectUri, - state, scopes, additionalParameters); + if (pushedAuthorizationRequest) { + return new OAuth2PushedAuthorizationRequestAuthenticationToken(authorizationUri, clientId, principal, + redirectUri, state, scopes, additionalParameters); + } + else { + return new OAuth2AuthorizationCodeRequestAuthenticationToken(authorizationUri, clientId, principal, + redirectUri, state, scopes, additionalParameters); + } + } + + private boolean isPushedAuthorizationRequest(HttpServletRequest request) { + AuthorizationServerContext authorizationServerContext = AuthorizationServerContextHolder.getContext(); + AuthorizationServerSettings authorizationServerSettings = authorizationServerContext + .getAuthorizationServerSettings(); + return request.getRequestURL() + .toString() + .toLowerCase(Locale.ROOT) + .endsWith(authorizationServerSettings.getPushedAuthorizationRequestEndpoint().toLowerCase(Locale.ROOT)); } private static RequestMatcher createDefaultRequestMatcher() { diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java index 332aa1c66..ef7dcbb88 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-2025 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. @@ -42,6 +42,8 @@ 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.OAuth2TokenType; +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; @@ -50,6 +52,7 @@ import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.util.StringUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -71,6 +74,8 @@ public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests { private static final String STATE = "state"; + private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); + private RegisteredClientRepository registeredClientRepository; private OAuth2AuthorizationService authorizationService; @@ -602,6 +607,59 @@ public void authenticateWhenAuthorizationCodeRequestValidThenReturnAuthorization authenticationResult); } + @Test + public void authenticateWhenAuthorizationCodeRequestWithRequestUriThenReturnAuthorizationCode() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .willReturn(registeredClient); + + OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = OAuth2PushedAuthorizationRequestUri + .create(); + Map additionalParameters = new HashMap<>(); + additionalParameters.put("request_uri", pushedAuthorizationRequestUri.getRequestUri()); + OAuth2Authorization authorization = TestOAuth2Authorizations + .authorization(registeredClient, additionalParameters) + .build(); + given(this.authorizationService.findByToken(eq(pushedAuthorizationRequestUri.getState()), eq(STATE_TOKEN_TYPE))) + .willReturn(authorization); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, null, null, null, + additionalParameters); + + OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider + .authenticate(authentication); + + assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication, + authenticationResult); + } + + @Test + public void authenticateWhenAuthorizationCodeRequestWithInvalidRequestUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .willReturn(registeredClient); + + OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = OAuth2PushedAuthorizationRequestUri + .create(); + Map additionalParameters = new HashMap<>(); + additionalParameters.put("request_uri", pushedAuthorizationRequestUri.getRequestUri()); + OAuth2Authorization authorization = TestOAuth2Authorizations + .authorization(registeredClient, additionalParameters) + .build(); + given(this.authorizationService.findByToken(eq(pushedAuthorizationRequestUri.getState()), eq(STATE_TOKEN_TYPE))) + .willReturn(authorization); + + OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, null, null, null, + Collections.singletonMap("request_uri", "invalid_request_uri")); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, "request_uri", null)); + } + @Test public void authenticateWhenAuthorizationCodeNotGeneratedThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); @@ -665,11 +723,15 @@ private void assertAuthorizationCodeRequestWithAuthorizationCodeResult(Registere 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()); + String requestUri = (String) authentication.getAdditionalParameters().get("request_uri"); + if (!StringUtils.hasText(requestUri)) { + 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); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProviderTests.java new file mode 100644 index 000000000..df4e8d7a6 --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProviderTests.java @@ -0,0 +1,422 @@ +/* + * Copyright 2020-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +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.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.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link OAuth2PushedAuthorizationRequestAuthenticationProvider}. + * + * @author Joe Grandja + */ +public class OAuth2PushedAuthorizationRequestAuthenticationProviderTests { + + private static final String AUTHORIZATION_URI = "https://provider.com/oauth2/par"; + + private static final String STATE = "state"; + + private OAuth2AuthorizationService authorizationService; + + private OAuth2PushedAuthorizationRequestAuthenticationProvider authenticationProvider; + + @BeforeEach + public void setUp() { + this.authorizationService = mock(OAuth2AuthorizationService.class); + this.authenticationProvider = new OAuth2PushedAuthorizationRequestAuthenticationProvider( + this.authorizationService); + } + + @Test + public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2PushedAuthorizationRequestAuthenticationProvider(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authorizationService cannot be null"); + } + + @Test + public void supportsWhenTypeOAuth2PushedAuthorizationRequestAuthenticationTokenThenReturnTrue() { + assertThat(this.authenticationProvider.supports(OAuth2PushedAuthorizationRequestAuthenticationToken.class)) + .isTrue(); + } + + @Test + public void setAuthenticationValidatorWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidator(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationValidator cannot be null"); + } + + @Test + public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1]; + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken( + registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE, + registeredClient.getScopes(), null); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + // @formatter:on + } + + @Test + public void authenticateWhenClientNotAuthorizedToRequestCodeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantTypes(Set::clear) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .build(); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1]; + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, null, + registeredClient.getScopes(), null); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID, + authentication.getRedirectUri())); + } + + @Test + public void authenticateWhenInvalidRedirectUriHostThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https:///invalid", STATE, + registeredClient.getScopes(), null); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null)); + } + + @Test + public void authenticateWhenInvalidRedirectUriFragmentThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https://example.com#fragment", + STATE, registeredClient.getScopes(), null); + 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(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https://invalid-example.com", + STATE, registeredClient.getScopes(), null); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null)); + } + + @Test + public void authenticateWhenRedirectUriIPv4LoopbackAndDifferentPortThenReturnPushedAuthorizationResponse() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .redirectUri("https://127.0.0.1:8080") + .build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https://127.0.0.1:5000", STATE, + registeredClient.getScopes(), null); + OAuth2PushedAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2PushedAuthorizationRequestAuthenticationToken) this.authenticationProvider + .authenticate(authentication); + assertPushedAuthorizationResponse(registeredClient, authentication, authenticationResult); + } + + @Test + public void authenticateWhenRedirectUriIPv6LoopbackAndDifferentPortThenReturnPushedAuthorizationResponse() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .redirectUri("https://[::1]:8080") + .build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https://[::1]:5000", STATE, + registeredClient.getScopes(), null); + OAuth2PushedAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2PushedAuthorizationRequestAuthenticationToken) this.authenticationProvider + .authenticate(authentication); + assertPushedAuthorizationResponse(registeredClient, authentication, authenticationResult); + } + + @Test + public void authenticateWhenMissingRedirectUriAndMultipleRegisteredThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .redirectUri("https://example2.com") + .build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, null, STATE, + registeredClient.getScopes(), null); + 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(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, null, STATE, + registeredClient.getScopes(), null); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null)); + } + + @Test + public void authenticateWhenInvalidScopeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE, + Collections.singleton("invalid-scope"), null); + 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.builder().requireProofKey(true).build()) + .build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE, + registeredClient.getScopes(), null); + 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(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; + Map additionalParameters = new HashMap<>(); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "unsupported"); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE, + registeredClient.getScopes(), additionalParameters); + 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 authenticateWhenPkceMissingCodeChallengeMethodThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; + Map additionalParameters = new HashMap<>(); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE, + registeredClient.getScopes(), additionalParameters); + 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 authenticateWhenAuthenticationRequestWithPromptNoneLoginThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + assertWhenAuthenticationRequestWithInvalidPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException( + "none login"); + } + + @Test + public void authenticateWhenAuthenticationRequestWithPromptNoneConsentThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + assertWhenAuthenticationRequestWithInvalidPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException( + "none consent"); + } + + @Test + public void authenticateWhenAuthenticationRequestWithPromptNoneSelectAccountThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() { + assertWhenAuthenticationRequestWithInvalidPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException( + "none select_account"); + } + + private void assertWhenAuthenticationRequestWithInvalidPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException( + String prompt) { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; + Map additionalParameters = new HashMap<>(); + additionalParameters.put("prompt", prompt); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE, + registeredClient.getScopes(), additionalParameters); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class) + .satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex, + OAuth2ErrorCodes.INVALID_REQUEST, "prompt", authentication.getRedirectUri())); + } + + @Test + public void authenticateWhenPushedAuthorizationRequestValidThenReturnPushedAuthorizationResponse() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[0]; + Map additionalParameters = new HashMap<>(); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); + additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE, + registeredClient.getScopes(), additionalParameters); + OAuth2PushedAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2PushedAuthorizationRequestAuthenticationToken) this.authenticationProvider + .authenticate(authentication); + assertPushedAuthorizationResponse(registeredClient, authentication, authenticationResult); + } + + @Test + public void authenticateWhenCustomAuthenticationValidatorThenUsed() { + @SuppressWarnings("unchecked") + Consumer authenticationValidator = mock(Consumer.class); + this.authenticationProvider.setAuthenticationValidator(authenticationValidator); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret()); + String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2]; + OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE, + registeredClient.getScopes(), null); + OAuth2PushedAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2PushedAuthorizationRequestAuthenticationToken) this.authenticationProvider + .authenticate(authentication); + assertPushedAuthorizationResponse(registeredClient, authentication, authenticationResult); + verify(authenticationValidator).accept(any()); + } + + private void assertPushedAuthorizationResponse(RegisteredClient registeredClient, + OAuth2PushedAuthorizationRequestAuthenticationToken authentication, + OAuth2PushedAuthorizationRequestAuthenticationToken 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(authentication.getName()); + assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE); + assertThat(authorization.getAttribute(OAuth2ParameterNames.STATE)).isNotNull(); + + assertThat(authenticationResult.getClientId()).isEqualTo(authorizationRequest.getClientId()); + assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal()); + assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(authorizationRequest.getAuthorizationUri()); + assertThat(authenticationResult.getRedirectUri()).isEqualTo(authorizationRequest.getRedirectUri()); + assertThat(authenticationResult.getScopes()).isEqualTo(authorizationRequest.getScopes()); + assertThat(authenticationResult.getState()).isEqualTo(authorizationRequest.getState()); + assertThat(authenticationResult.getRequestUri()).isNotNull(); + assertThat(authenticationResult.getRequestUriExpiresAt()).isNotNull(); + assertThat(authenticationResult.isAuthenticated()).isTrue(); + } + + 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); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java index e2099eac7..6df4caf69 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java @@ -32,6 +32,7 @@ import java.util.UUID; import java.util.function.Consumer; +import com.jayway.jsonpath.JsonPath; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; @@ -1012,6 +1013,67 @@ public void requestWhenTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() assertThat(cnfClaims).containsKey("jkt"); } + @Test + public void requestWhenPushedAuthorizationRequestThenReturnAccessTokenResponse() throws Exception { + this.spring.register(AuthorizationServerConfigurationWithPushedAuthorizationRequests.class).autowire(); + + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + this.registeredClientRepository.save(registeredClient); + + MvcResult mvcResult = this.mvc + .perform(post("/oauth2/par").params(getAuthorizationRequestParameters(registeredClient)) + .param(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE) + .param(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256") + .header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient))) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store"))) + .andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache"))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.request_uri").isNotEmpty()) + .andExpect(jsonPath("$.expires_in").isNotEmpty()) + .andReturn(); + + String requestUri = JsonPath.read(mvcResult.getResponse().getContentAsString(), "$.request_uri"); + + mvcResult = this.mvc + .perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI) + .queryParam(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()) + .queryParam("request_uri", requestUri) + .with(user("user"))) + .andExpect(status().is3xxRedirection()) + .andReturn(); + + String authorizationCode = extractParameterFromRedirectUri(mvcResult.getResponse().getRedirectedUrl(), "code"); + OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode, + AUTHORIZATION_CODE_TOKEN_TYPE); + + this.mvc + .perform(post(DEFAULT_TOKEN_ENDPOINT_URI) + .params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization)) + .param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()) + .param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER) + .header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient))) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store"))) + .andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.token_type").isNotEmpty()) + .andExpect(jsonPath("$.expires_in").isNotEmpty()) + .andExpect(jsonPath("$.refresh_token").isNotEmpty()) + .andExpect(jsonPath("$.scope").isNotEmpty()) + .andReturn(); + + OAuth2Authorization accessTokenAuthorization = this.authorizationService + .findById(authorizationCodeAuthorization.getId()); + assertThat(accessTokenAuthorization).isNotNull(); + assertThat(accessTokenAuthorization.getAccessToken()).isNotNull(); + + OAuth2Authorization.Token authorizationCodeToken = accessTokenAuthorization + .getToken(OAuth2AuthorizationCode.class); + assertThat(authorizationCodeToken).isNotNull(); + assertThat(authorizationCodeToken.getMetadata().get(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME)) + .isEqualTo(true); + } + private static String generateDPoPProof(String tokenEndpointUri) { // @formatter:off Map publicJwk = TestJwks.DEFAULT_EC_JWK @@ -1417,4 +1479,29 @@ AuthorizationServerSettings authorizationServerSettings() { } + @EnableWebSecurity + @Configuration(proxyBeanMethods = false) + static class AuthorizationServerConfigurationWithPushedAuthorizationRequests + extends AuthorizationServerConfiguration { + + // @formatter:off + @Bean + SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + OAuth2AuthorizationServerConfigurer.authorizationServer(); + http + .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) + .with(authorizationServerConfigurer, (authorizationServer) -> + authorizationServer + .pushedAuthorizationRequestEndpoint(Customizer.withDefaults()) + ) + .authorizeHttpRequests((authorize) -> + authorize.anyRequest().authenticated() + ); + return http.build(); + } + // @formatter:on + + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java index eb8097752..551c78564 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-2025 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. @@ -35,6 +35,7 @@ public void buildWhenDefaultThenDefaultsAreSet() { assertThat(authorizationServerSettings.getIssuer()).isNull(); assertThat(authorizationServerSettings.isMultipleIssuersAllowed()).isFalse(); assertThat(authorizationServerSettings.getAuthorizationEndpoint()).isEqualTo("/oauth2/authorize"); + assertThat(authorizationServerSettings.getPushedAuthorizationRequestEndpoint()).isEqualTo("/oauth2/par"); assertThat(authorizationServerSettings.getTokenEndpoint()).isEqualTo("/oauth2/token"); assertThat(authorizationServerSettings.getJwkSetEndpoint()).isEqualTo("/oauth2/jwks"); assertThat(authorizationServerSettings.getTokenRevocationEndpoint()).isEqualTo("/oauth2/revoke"); @@ -47,6 +48,7 @@ public void buildWhenDefaultThenDefaultsAreSet() { @Test public void buildWhenSettingsProvidedThenSet() { String authorizationEndpoint = "/oauth2/v1/authorize"; + String pushedAuthorizationRequestEndpoint = "/oauth2/v1/par"; String tokenEndpoint = "/oauth2/v1/token"; String jwkSetEndpoint = "/oauth2/v1/jwks"; String tokenRevocationEndpoint = "/oauth2/v1/revoke"; @@ -59,6 +61,7 @@ public void buildWhenSettingsProvidedThenSet() { AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder() .issuer(issuer) .authorizationEndpoint(authorizationEndpoint) + .pushedAuthorizationRequestEndpoint(pushedAuthorizationRequestEndpoint) .tokenEndpoint(tokenEndpoint) .jwkSetEndpoint(jwkSetEndpoint) .tokenRevocationEndpoint(tokenRevocationEndpoint) @@ -72,6 +75,8 @@ public void buildWhenSettingsProvidedThenSet() { assertThat(authorizationServerSettings.getIssuer()).isEqualTo(issuer); assertThat(authorizationServerSettings.isMultipleIssuersAllowed()).isFalse(); assertThat(authorizationServerSettings.getAuthorizationEndpoint()).isEqualTo(authorizationEndpoint); + assertThat(authorizationServerSettings.getPushedAuthorizationRequestEndpoint()) + .isEqualTo(pushedAuthorizationRequestEndpoint); assertThat(authorizationServerSettings.getTokenEndpoint()).isEqualTo(tokenEndpoint); assertThat(authorizationServerSettings.getJwkSetEndpoint()).isEqualTo(jwkSetEndpoint); assertThat(authorizationServerSettings.getTokenRevocationEndpoint()).isEqualTo(tokenRevocationEndpoint); @@ -100,6 +105,7 @@ public void buildWhenIssuerNotSetAndMultipleIssuersAllowedTrueThenDefaultsAreSet assertThat(authorizationServerSettings.getIssuer()).isNull(); assertThat(authorizationServerSettings.isMultipleIssuersAllowed()).isTrue(); assertThat(authorizationServerSettings.getAuthorizationEndpoint()).isEqualTo("/oauth2/authorize"); + assertThat(authorizationServerSettings.getPushedAuthorizationRequestEndpoint()).isEqualTo("/oauth2/par"); assertThat(authorizationServerSettings.getTokenEndpoint()).isEqualTo("/oauth2/token"); assertThat(authorizationServerSettings.getJwkSetEndpoint()).isEqualTo("/oauth2/jwks"); assertThat(authorizationServerSettings.getTokenRevocationEndpoint()).isEqualTo("/oauth2/revoke"); @@ -116,7 +122,7 @@ public void settingWhenCustomThenSet() { .settings((settings) -> settings.put("name2", "value2")) .build(); - assertThat(authorizationServerSettings.getSettings()).hasSize(13); + assertThat(authorizationServerSettings.getSettings()).hasSize(14); assertThat(authorizationServerSettings.getSetting("name1")).isEqualTo("value1"); assertThat(authorizationServerSettings.getSetting("name2")).isEqualTo("value2"); } @@ -134,6 +140,13 @@ public void authorizationEndpointWhenNullThenThrowIllegalArgumentException() { .withMessage("value cannot be null"); } + @Test + public void pushedAuthorizationRequestEndpointWhenNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> AuthorizationServerSettings.builder().pushedAuthorizationRequestEndpoint(null)) + .withMessage("value cannot be null"); + } + @Test public void tokenEndpointWhenNullThenThrowIllegalArgumentException() { assertThatIllegalArgumentException().isThrownBy(() -> AuthorizationServerSettings.builder().tokenEndpoint(null)) 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 ac4f81e18..06b8316ed 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 @@ -54,6 +54,9 @@ import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationToken; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @@ -112,11 +115,14 @@ public void setUp() { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(5, ChronoUnit.MINUTES); this.authorizationCode = new OAuth2AuthorizationCode("code", issuedAt, expiresAt); + AuthorizationServerContextHolder + .setContext(new TestAuthorizationServerContext(AuthorizationServerSettings.builder().build(), null)); } @AfterEach public void cleanup() { SecurityContextHolder.clearContext(); + AuthorizationServerContextHolder.resetContext(); } @Test @@ -181,6 +187,16 @@ public void doFilterWhenNotAuthorizationRequestThenNotProcessed() throws Excepti verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); } + @Test + public void doFilterWhenAuthorizationRequestMultipleRequestUriThenInvalidRequestError() throws Exception { + doFilterWhenAuthorizationRequestInvalidParameterThenError(TestRegisteredClients.registeredClient().build(), + "request_uri", OAuth2ErrorCodes.INVALID_REQUEST, (request) -> { + request.addParameter("request_uri", "request_uri"); + request.addParameter("request_uri", "request_uri_2"); + updateQueryString(request); + }); + } + @Test public void doFilterWhenAuthorizationRequestMissingResponseTypeThenInvalidRequestError() throws Exception { doFilterWhenAuthorizationRequestInvalidParameterThenError(TestRegisteredClients.registeredClient().build(), diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2PushedAuthorizationRequestEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2PushedAuthorizationRequestEndpointFilterTests.java new file mode 100644 index 000000000..ee5930a4f --- /dev/null +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2PushedAuthorizationRequestEndpointFilterTests.java @@ -0,0 +1,490 @@ +/* + * Copyright 2020-2025 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.web; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.function.Consumer; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +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.endpoint.OAuth2AuthorizationResponseType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2PushedAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetails; +import org.springframework.util.StringUtils; + +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.same; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link OAuth2PushedAuthorizationRequestEndpointFilter}. + * + * @author Joe Grandja + */ +public class OAuth2PushedAuthorizationRequestEndpointFilterTests { + + private static final String AUTHORIZATION_URI = "https://provider.com/oauth2/par"; + + private static final String STATE = "state"; + + private static final String REMOTE_ADDRESS = "remote-address"; + + private final HttpMessageConverter errorHttpResponseConverter = new OAuth2ErrorHttpMessageConverter(); + + private final GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters + .getJsonMessageConverter(); + + private AuthenticationManager authenticationManager; + + private OAuth2PushedAuthorizationRequestEndpointFilter filter; + + private TestingAuthenticationToken clientPrincipal; + + @BeforeEach + public void setUp() { + this.authenticationManager = mock(AuthenticationManager.class); + this.filter = new OAuth2PushedAuthorizationRequestEndpointFilter(this.authenticationManager); + this.clientPrincipal = new TestingAuthenticationToken("client-id", "client-secret"); + this.clientPrincipal.setAuthenticated(true); + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(this.clientPrincipal); + SecurityContextHolder.setContext(securityContext); + AuthorizationServerContextHolder + .setContext(new TestAuthorizationServerContext(AuthorizationServerSettings.builder().build(), null)); + } + + @AfterEach + public void cleanup() { + SecurityContextHolder.clearContext(); + AuthorizationServerContextHolder.resetContext(); + } + + @Test + public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2PushedAuthorizationRequestEndpointFilter(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationManager cannot be null"); + } + + @Test + public void constructorWhenPushedAuthorizationRequestEndpointUriNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2PushedAuthorizationRequestEndpointFilter(this.authenticationManager, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("pushedAuthorizationRequestEndpointUri cannot be empty"); + } + + @Test + public void setAuthenticationDetailsSourceWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.filter.setAuthenticationDetailsSource(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationDetailsSource cannot be null"); + } + + @Test + public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.filter.setAuthenticationConverter(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationConverter cannot be null"); + } + + @Test + public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationSuccessHandler cannot be null"); + } + + @Test + public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> this.filter.setAuthenticationFailureHandler(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("authenticationFailureHandler cannot be null"); + } + + @Test + public void doFilterWhenNotPushedAuthorizationRequestThenNotProcessed() throws Exception { + String requestUri = "/path"; + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setServletPath(requestUri); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class)); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestIncludesRequestUriThenInvalidRequestError() throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), "request_uri", OAuth2ErrorCodes.INVALID_REQUEST, + (request) -> request.addParameter("request_uri", "request_uri")); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestMultipleResponseTypeThenInvalidRequestError() throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.RESPONSE_TYPE, + OAuth2ErrorCodes.INVALID_REQUEST, + (request) -> request.addParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token")); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestInvalidResponseTypeThenUnsupportedResponseTypeError() + throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.RESPONSE_TYPE, + OAuth2ErrorCodes.UNSUPPORTED_RESPONSE_TYPE, + (request) -> request.setParameter(OAuth2ParameterNames.RESPONSE_TYPE, "id_token")); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestMissingClientIdThenInvalidRequestError() throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.CLIENT_ID, + OAuth2ErrorCodes.INVALID_REQUEST, (request) -> request.removeParameter(OAuth2ParameterNames.CLIENT_ID)); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestMultipleClientIdThenInvalidRequestError() throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.CLIENT_ID, + OAuth2ErrorCodes.INVALID_REQUEST, + (request) -> request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-2")); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestMultipleRedirectUriThenInvalidRequestError() throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.REDIRECT_URI, + OAuth2ErrorCodes.INVALID_REQUEST, + (request) -> request.addParameter(OAuth2ParameterNames.REDIRECT_URI, "https://example2.com")); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestMultipleScopeThenInvalidRequestError() throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.SCOPE, + OAuth2ErrorCodes.INVALID_REQUEST, + (request) -> request.addParameter(OAuth2ParameterNames.SCOPE, "scope2")); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestMultipleStateThenInvalidRequestError() throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), OAuth2ParameterNames.STATE, + OAuth2ErrorCodes.INVALID_REQUEST, + (request) -> request.addParameter(OAuth2ParameterNames.STATE, "state2")); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestMultipleCodeChallengeThenInvalidRequestError() throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), PkceParameterNames.CODE_CHALLENGE, + OAuth2ErrorCodes.INVALID_REQUEST, (request) -> { + request.addParameter(PkceParameterNames.CODE_CHALLENGE, "code-challenge"); + request.addParameter(PkceParameterNames.CODE_CHALLENGE, "another-code-challenge"); + }); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestMultipleCodeChallengeMethodThenInvalidRequestError() + throws Exception { + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError( + TestRegisteredClients.registeredClient().build(), PkceParameterNames.CODE_CHALLENGE_METHOD, + OAuth2ErrorCodes.INVALID_REQUEST, (request) -> { + request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); + request.addParameter(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); + }); + } + + @Test + public void doFilterWhenPushedAuthenticationRequestMultiplePromptThenInvalidRequestError() throws Exception { + // Setup OpenID Connect request + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> { + scopes.clear(); + scopes.add(OidcScopes.OPENID); + }).build(); + doFilterWhenPushedAuthorizationRequestInvalidParameterThenError(registeredClient, "prompt", + OAuth2ErrorCodes.INVALID_REQUEST, (request) -> { + request.addParameter("prompt", "none"); + request.addParameter("prompt", "login"); + }); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestAuthenticationExceptionThenErrorResponse() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, "error description", "error uri"); + given(this.authenticationManager.authenticate(any())).willThrow(new OAuth2AuthenticationException(error)); + + MockHttpServletRequest request = createPushedAuthorizationRequest(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.BAD_REQUEST.value()); + OAuth2Error errorResponse = readError(response); + assertThat(errorResponse.getErrorCode()).isEqualTo(error.getErrorCode()); + assertThat(errorResponse.getDescription()).isEqualTo(error.getDescription()); + assertThat(errorResponse.getUri()).isEqualTo(error.getUri()); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(this.clientPrincipal); + } + + @Test + public void doFilterWhenCustomAuthenticationConverterThenUsed() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2PushedAuthorizationRequestAuthenticationToken pushedAuthorizationRequestAuthenticationResult = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), this.clientPrincipal, "request_uri", + Instant.now().plusSeconds(30), registeredClient.getRedirectUris().iterator().next(), STATE, + registeredClient.getScopes()); + + AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class); + given(authenticationConverter.convert(any())).willReturn(pushedAuthorizationRequestAuthenticationResult); + this.filter.setAuthenticationConverter(authenticationConverter); + + given(this.authenticationManager.authenticate(any())) + .willReturn(pushedAuthorizationRequestAuthenticationResult); + + MockHttpServletRequest request = createPushedAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(authenticationConverter).convert(any()); + verify(this.authenticationManager).authenticate(any()); + } + + @Test + public void doFilterWhenCustomAuthenticationSuccessHandlerThenUsed() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2PushedAuthorizationRequestAuthenticationToken pushedAuthorizationRequestAuthenticationResult = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), this.clientPrincipal, "request_uri", + Instant.now().plusSeconds(30), registeredClient.getRedirectUris().iterator().next(), STATE, + registeredClient.getScopes()); + given(this.authenticationManager.authenticate(any())) + .willReturn(pushedAuthorizationRequestAuthenticationResult); + + AuthenticationSuccessHandler authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class); + this.filter.setAuthenticationSuccessHandler(authenticationSuccessHandler); + + MockHttpServletRequest request = createPushedAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), + same(pushedAuthorizationRequestAuthenticationResult)); + } + + @Test + public void doFilterWhenCustomAuthenticationFailureHandlerThenUsed() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2Error error = new OAuth2Error("errorCode", "errorDescription", "errorUri"); + OAuth2AuthenticationException authenticationException = new OAuth2AuthenticationException(error); + given(this.authenticationManager.authenticate(any())).willThrow(authenticationException); + + AuthenticationFailureHandler authenticationFailureHandler = mock(AuthenticationFailureHandler.class); + this.filter.setAuthenticationFailureHandler(authenticationFailureHandler); + + MockHttpServletRequest request = createPushedAuthorizationRequest(registeredClient); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(this.authenticationManager).authenticate(any()); + verifyNoInteractions(filterChain); + verify(authenticationFailureHandler).onAuthenticationFailure(any(), any(), same(authenticationException)); + } + + @Test + public void doFilterWhenCustomAuthenticationDetailsSourceThenUsed() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + MockHttpServletRequest request = createPushedAuthorizationRequest(registeredClient); + + AuthenticationDetailsSource authenticationDetailsSource = mock( + AuthenticationDetailsSource.class); + WebAuthenticationDetails webAuthenticationDetails = new WebAuthenticationDetails(request); + given(authenticationDetailsSource.buildDetails(request)).willReturn(webAuthenticationDetails); + this.filter.setAuthenticationDetailsSource(authenticationDetailsSource); + + OAuth2PushedAuthorizationRequestAuthenticationToken pushedAuthorizationRequestAuthenticationResult = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), this.clientPrincipal, "request_uri", + Instant.now().plusSeconds(30), registeredClient.getRedirectUris().iterator().next(), STATE, + registeredClient.getScopes()); + + given(this.authenticationManager.authenticate(any())) + .willReturn(pushedAuthorizationRequestAuthenticationResult); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + verify(authenticationDetailsSource).buildDetails(any()); + verify(this.authenticationManager).authenticate(any()); + } + + @Test + public void doFilterWhenPushedAuthorizationRequestAuthenticatedThenPushedAuthorizationResponse() throws Exception { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + String requestUri = "request_uri"; + Instant requestUriExpiresAt = Instant.now().plusSeconds(30); + OAuth2PushedAuthorizationRequestAuthenticationToken pushedAuthorizationRequestAuthenticationResult = new OAuth2PushedAuthorizationRequestAuthenticationToken( + AUTHORIZATION_URI, registeredClient.getClientId(), this.clientPrincipal, requestUri, + requestUriExpiresAt, registeredClient.getRedirectUris().iterator().next(), STATE, + registeredClient.getScopes()); + given(this.authenticationManager.authenticate(any())) + .willReturn(pushedAuthorizationRequestAuthenticationResult); + + MockHttpServletRequest request = createPushedAuthorizationRequest(registeredClient); + request.addParameter("custom-param", "custom-value-1", "custom-value-2"); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + this.filter.doFilter(request, response, filterChain); + + ArgumentCaptor pushedAuthorizationRequestAuthenticationCaptor = ArgumentCaptor + .forClass(OAuth2PushedAuthorizationRequestAuthenticationToken.class); + verify(this.authenticationManager).authenticate(pushedAuthorizationRequestAuthenticationCaptor.capture()); + verifyNoInteractions(filterChain); + + assertThat(pushedAuthorizationRequestAuthenticationCaptor.getValue().getDetails()) + .asInstanceOf(InstanceOfAssertFactories.type(WebAuthenticationDetails.class)) + .extracting(WebAuthenticationDetails::getRemoteAddress) + .isEqualTo(REMOTE_ADDRESS); + + // Assert that multi-valued request parameters are preserved + assertThat(pushedAuthorizationRequestAuthenticationCaptor.getValue().getAdditionalParameters()) + .extracting((params) -> params.get("custom-param")) + .asInstanceOf(InstanceOfAssertFactories.type(String[].class)) + .isEqualTo(new String[] { "custom-value-1", "custom-value-2" }); + assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED.value()); + Map responseParameters = readPushedAuthorizationResponse(response); + assertThat(responseParameters.get("request_uri")).isEqualTo(requestUri); + assertThat(responseParameters.get("expires_in")) + .isEqualTo((int) ChronoUnit.SECONDS.between(Instant.now(), requestUriExpiresAt)); + } + + private void doFilterWhenPushedAuthorizationRequestInvalidParameterThenError(RegisteredClient registeredClient, + String parameterName, String errorCode, Consumer requestConsumer) throws Exception { + + doFilterWhenRequestInvalidParameterThenError(createPushedAuthorizationRequest(registeredClient), parameterName, + errorCode, requestConsumer); + } + + private void doFilterWhenRequestInvalidParameterThenError(MockHttpServletRequest request, String parameterName, + String errorCode, 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.BAD_REQUEST.value()); + OAuth2Error error = readError(response); + assertThat(error.getErrorCode()).isEqualTo(errorCode); + assertThat(error.getDescription()).isEqualTo("OAuth 2.0 Parameter: " + parameterName); + } + + private static MockHttpServletRequest createPushedAuthorizationRequest(RegisteredClient registeredClient) { + String requestUri = AuthorizationServerContextHolder.getContext() + .getAuthorizationServerSettings() + .getPushedAuthorizationRequestEndpoint(); + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setServletPath(requestUri); + request.setRemoteAddr(REMOTE_ADDRESS); + + request.addParameter(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue()); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()); + request.addParameter(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next()); + request.addParameter(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " ")); + request.addParameter(OAuth2ParameterNames.STATE, "state"); + + return request; + } + + private OAuth2Error readError(MockHttpServletResponse response) throws Exception { + MockClientHttpResponse httpResponse = new MockClientHttpResponse(response.getContentAsByteArray(), + HttpStatus.valueOf(response.getStatus())); + return this.errorHttpResponseConverter.read(OAuth2Error.class, httpResponse); + } + + @SuppressWarnings("unchecked") + private Map readPushedAuthorizationResponse(MockHttpServletResponse response) throws Exception { + final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { + }; + MockClientHttpResponse httpResponse = new MockClientHttpResponse(response.getContentAsByteArray(), + HttpStatus.valueOf(response.getStatus())); + return (Map) this.jsonMessageConverter.read(STRING_OBJECT_MAP.getType(), null, httpResponse); + } + +}