Skip to content

Add support for OAuth 2.0 Pushed Authorization Requests (PAR) #1925

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions etc/checkstyle/checkstyle-suppressions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
<suppress files="SpringAuthorizationServerVersion\.java" checks="HideUtilityClassConstructor"/>
<suppress files="[\\/]src[\\/]test[\\/]" checks="RegexpSinglelineJava" id="toLowerCaseWithoutLocale"/>
<suppress files="[\\/]src[\\/]test[\\/]" checks="RegexpSinglelineJava" id="toUpperCaseWithoutLocale"/>
<suppress files="AbstractOAuth2AuthorizationCodeRequestAuthenticationToken\.java" checks="SpringMethodVisibility"/>
</suppressions>
Original file line number Diff line number Diff line change
@@ -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<String> scopes;

private final Map<String, Object> additionalParameters;

protected AbstractOAuth2AuthorizationCodeRequestAuthenticationToken(String authorizationUri, String clientId,
Authentication principal, @Nullable String redirectUri, @Nullable String state,
@Nullable Set<String> scopes, @Nullable Map<String, Object> 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<String> getScopes() {
return this.scopes;
}

/**
* Returns the additional parameters.
* @return the additional parameters, or an empty {@code Map} if not available
*/
public Map<String, Object> getAdditionalParameters() {
return this.additionalParameters;
}

}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -206,6 +206,8 @@ private static List<String> getAudience() {
authorizationServerSettings.getTokenIntrospectionEndpoint()));
audience.add(asUrl(authorizationServerContext.getIssuer(),
authorizationServerSettings.getTokenRevocationEndpoint()));
audience.add(asUrl(authorizationServerContext.getIssuer(),
authorizationServerSettings.getPushedAuthorizationRequestEndpoint()));
return audience;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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) {
Expand All @@ -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<String> 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);
}
}
}
}

Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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() {
}

}

}
Loading