Skip to content

Commit 559c847

Browse files
committed
Add support for OAuth 2.0 Pushed Authorization Requests (PAR)
Closes gh-210 Signed-off-by: Joe Grandja <[email protected]>
1 parent 629239f commit 559c847

24 files changed

+2481
-214
lines changed

etc/checkstyle/checkstyle-suppressions.xml

+1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
<suppress files="SpringAuthorizationServerVersion\.java" checks="HideUtilityClassConstructor"/>
88
<suppress files="[\\/]src[\\/]test[\\/]" checks="RegexpSinglelineJava" id="toLowerCaseWithoutLocale"/>
99
<suppress files="[\\/]src[\\/]test[\\/]" checks="RegexpSinglelineJava" id="toUpperCaseWithoutLocale"/>
10+
<suppress files="AbstractOAuth2AuthorizationCodeRequestAuthenticationToken\.java" checks="SpringMethodVisibility"/>
1011
</suppressions>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.security.oauth2.server.authorization.authentication;
17+
18+
import java.util.Collections;
19+
import java.util.HashMap;
20+
import java.util.HashSet;
21+
import java.util.Map;
22+
import java.util.Set;
23+
24+
import org.springframework.lang.Nullable;
25+
import org.springframework.security.authentication.AbstractAuthenticationToken;
26+
import org.springframework.security.core.Authentication;
27+
import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion;
28+
import org.springframework.util.Assert;
29+
30+
/**
31+
* An {@link Authentication} base implementation for the OAuth 2.0 Authorization Request
32+
* used in the Authorization Code Grant.
33+
*
34+
* @author Joe Grandja
35+
* @since 1.5
36+
* @see OAuth2AuthorizationCodeRequestAuthenticationToken
37+
* @see OAuth2PushedAuthorizationRequestAuthenticationToken
38+
*/
39+
abstract class AbstractOAuth2AuthorizationCodeRequestAuthenticationToken extends AbstractAuthenticationToken {
40+
41+
private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
42+
43+
private final String authorizationUri;
44+
45+
private final String clientId;
46+
47+
private final Authentication principal;
48+
49+
private final String redirectUri;
50+
51+
private final String state;
52+
53+
private final Set<String> scopes;
54+
55+
private final Map<String, Object> additionalParameters;
56+
57+
protected AbstractOAuth2AuthorizationCodeRequestAuthenticationToken(String authorizationUri, String clientId,
58+
Authentication principal, @Nullable String redirectUri, @Nullable String state,
59+
@Nullable Set<String> scopes, @Nullable Map<String, Object> additionalParameters) {
60+
super(Collections.emptyList());
61+
Assert.hasText(authorizationUri, "authorizationUri cannot be empty");
62+
Assert.hasText(clientId, "clientId cannot be empty");
63+
Assert.notNull(principal, "principal cannot be null");
64+
this.authorizationUri = authorizationUri;
65+
this.clientId = clientId;
66+
this.principal = principal;
67+
this.redirectUri = redirectUri;
68+
this.state = state;
69+
this.scopes = Collections.unmodifiableSet((scopes != null) ? new HashSet<>(scopes) : Collections.emptySet());
70+
this.additionalParameters = Collections.unmodifiableMap(
71+
(additionalParameters != null) ? new HashMap<>(additionalParameters) : Collections.emptyMap());
72+
}
73+
74+
@Override
75+
public Object getPrincipal() {
76+
return this.principal;
77+
}
78+
79+
@Override
80+
public Object getCredentials() {
81+
return "";
82+
}
83+
84+
/**
85+
* Returns the authorization URI.
86+
* @return the authorization URI
87+
*/
88+
public String getAuthorizationUri() {
89+
return this.authorizationUri;
90+
}
91+
92+
/**
93+
* Returns the client identifier.
94+
* @return the client identifier
95+
*/
96+
public String getClientId() {
97+
return this.clientId;
98+
}
99+
100+
/**
101+
* Returns the redirect uri.
102+
* @return the redirect uri
103+
*/
104+
@Nullable
105+
public String getRedirectUri() {
106+
return this.redirectUri;
107+
}
108+
109+
/**
110+
* Returns the state.
111+
* @return the state
112+
*/
113+
@Nullable
114+
public String getState() {
115+
return this.state;
116+
}
117+
118+
/**
119+
* Returns the requested (or authorized) scope(s).
120+
* @return the requested (or authorized) scope(s), or an empty {@code Set} if not
121+
* available
122+
*/
123+
public Set<String> getScopes() {
124+
return this.scopes;
125+
}
126+
127+
/**
128+
* Returns the additional parameters.
129+
* @return the additional parameters, or an empty {@code Map} if not available
130+
*/
131+
public Map<String, Object> getAdditionalParameters() {
132+
return this.additionalParameters;
133+
}
134+
135+
}

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactory.java

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2023 the original author or authors.
2+
* Copyright 2020-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -206,6 +206,8 @@ private static List<String> getAudience() {
206206
authorizationServerSettings.getTokenIntrospectionEndpoint()));
207207
audience.add(asUrl(authorizationServerContext.getIssuer(),
208208
authorizationServerSettings.getTokenRevocationEndpoint()));
209+
audience.add(asUrl(authorizationServerContext.getIssuer(),
210+
authorizationServerSettings.getPushedAuthorizationRequestEndpoint()));
209211
return audience;
210212
}
211213

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

+54-56
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2024 the original author or authors.
2+
* Copyright 2020-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -27,7 +27,6 @@
2727
import org.apache.commons.logging.Log;
2828
import org.apache.commons.logging.LogFactory;
2929

30-
import org.springframework.core.log.LogMessage;
3130
import org.springframework.security.authentication.AnonymousAuthenticationToken;
3231
import org.springframework.security.authentication.AuthenticationProvider;
3332
import org.springframework.security.core.Authentication;
@@ -39,7 +38,6 @@
3938
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
4039
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
4140
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
42-
import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
4341
import org.springframework.security.oauth2.core.oidc.OidcScopes;
4442
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
4543
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
@@ -81,7 +79,7 @@ public final class OAuth2AuthorizationCodeRequestAuthenticationProvider implemen
8179

8280
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1";
8381

84-
private static final String PKCE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1";
82+
private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
8583

8684
private static final StringKeyGenerator DEFAULT_STATE_GENERATOR = new Base64StringKeyGenerator(
8785
Base64.getUrlEncoder());
@@ -122,6 +120,13 @@ public OAuth2AuthorizationCodeRequestAuthenticationProvider(RegisteredClientRepo
122120
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
123121
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication;
124122

123+
String requestUri = (String) authorizationCodeRequestAuthentication.getAdditionalParameters()
124+
.get("request_uri");
125+
if (StringUtils.hasText(requestUri)) {
126+
authorizationCodeRequestAuthentication = fromPushedAuthorizationRequest(
127+
authorizationCodeRequestAuthentication);
128+
}
129+
125130
RegisteredClient registeredClient = this.registeredClientRepository
126131
.findByClientId(authorizationCodeRequestAuthentication.getClientId());
127132
if (registeredClient == null) {
@@ -136,47 +141,28 @@ public Authentication authenticate(Authentication authentication) throws Authent
136141
OAuth2AuthorizationCodeRequestAuthenticationContext.Builder authenticationContextBuilder = OAuth2AuthorizationCodeRequestAuthenticationContext
137142
.with(authorizationCodeRequestAuthentication)
138143
.registeredClient(registeredClient);
139-
this.authenticationValidator.accept(authenticationContextBuilder.build());
144+
OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext = authenticationContextBuilder
145+
.build();
140146

141-
if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE)) {
142-
if (this.logger.isDebugEnabled()) {
143-
this.logger.debug(LogMessage.format(
144-
"Invalid request: requested grant_type is not allowed" + " for registered client '%s'",
145-
registeredClient.getId()));
146-
}
147-
throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID,
148-
authorizationCodeRequestAuthentication, registeredClient);
149-
}
147+
// grant_type
148+
OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_AUTHORIZATION_GRANT_TYPE_VALIDATOR
149+
.accept(authenticationContext);
150+
151+
// redirect_uri and scope
152+
this.authenticationValidator.accept(authenticationContext);
150153

151154
// code_challenge (REQUIRED for public clients) - RFC 7636 (PKCE)
152-
String codeChallenge = (String) authorizationCodeRequestAuthentication.getAdditionalParameters()
153-
.get(PkceParameterNames.CODE_CHALLENGE);
154-
if (StringUtils.hasText(codeChallenge)) {
155-
String codeChallengeMethod = (String) authorizationCodeRequestAuthentication.getAdditionalParameters()
156-
.get(PkceParameterNames.CODE_CHALLENGE_METHOD);
157-
if (!StringUtils.hasText(codeChallengeMethod) || !"S256".equals(codeChallengeMethod)) {
158-
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD, PKCE_ERROR_URI,
159-
authorizationCodeRequestAuthentication, registeredClient, null);
160-
}
161-
}
162-
else if (registeredClient.getClientSettings().isRequireProofKey()) {
163-
throwError(OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE, PKCE_ERROR_URI,
164-
authorizationCodeRequestAuthentication, registeredClient, null);
165-
}
155+
OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_CODE_CHALLENGE_VALIDATOR
156+
.accept(authenticationContext);
166157

167158
// prompt (OPTIONAL for OpenID Connect 1.0 Authentication Request)
168159
Set<String> promptValues = Collections.emptySet();
169160
if (authorizationCodeRequestAuthentication.getScopes().contains(OidcScopes.OPENID)) {
170161
String prompt = (String) authorizationCodeRequestAuthentication.getAdditionalParameters().get("prompt");
171162
if (StringUtils.hasText(prompt)) {
163+
OAuth2AuthorizationCodeRequestAuthenticationValidator.DEFAULT_PROMPT_VALIDATOR
164+
.accept(authenticationContext);
172165
promptValues = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(prompt, " ")));
173-
if (promptValues.contains(OidcPrompts.NONE)) {
174-
if (promptValues.contains(OidcPrompts.LOGIN) || promptValues.contains(OidcPrompts.CONSENT)
175-
|| promptValues.contains(OidcPrompts.SELECT_ACCOUNT)) {
176-
throwError(OAuth2ErrorCodes.INVALID_REQUEST, "prompt", authorizationCodeRequestAuthentication,
177-
registeredClient);
178-
}
179-
}
180166
}
181167
}
182168

@@ -190,7 +176,7 @@ else if (registeredClient.getClientSettings().isRequireProofKey()) {
190176

191177
Authentication principal = (Authentication) authorizationCodeRequestAuthentication.getPrincipal();
192178
if (!isPrincipalAuthenticated(principal)) {
193-
if (promptValues.contains(OidcPrompts.NONE)) {
179+
if (promptValues.contains(OidcPrompt.NONE)) {
194180
// Return an error instead of displaying the login page (via the
195181
// configured AuthenticationEntryPoint)
196182
throwError("login_required", "prompt", authorizationCodeRequestAuthentication, registeredClient);
@@ -219,7 +205,7 @@ else if (registeredClient.getClientSettings().isRequireProofKey()) {
219205
}
220206

221207
if (this.authorizationConsentRequired.test(authenticationContextBuilder.build())) {
222-
if (promptValues.contains(OidcPrompts.NONE)) {
208+
if (promptValues.contains(OidcPrompt.NONE)) {
223209
// Return an error instead of displaying the consent page
224210
throwError("consent_required", "prompt", authorizationCodeRequestAuthentication, registeredClient);
225211
}
@@ -347,6 +333,37 @@ public void setAuthorizationConsentRequired(
347333
this.authorizationConsentRequired = authorizationConsentRequired;
348334
}
349335

336+
private OAuth2AuthorizationCodeRequestAuthenticationToken fromPushedAuthorizationRequest(
337+
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication) {
338+
339+
String requestUri = (String) authorizationCodeRequestAuthentication.getAdditionalParameters()
340+
.get("request_uri");
341+
342+
OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = null;
343+
try {
344+
pushedAuthorizationRequestUri = OAuth2PushedAuthorizationRequestUri.parse(requestUri);
345+
}
346+
catch (Exception ex) {
347+
throwError(OAuth2ErrorCodes.INVALID_REQUEST, "request_uri", authorizationCodeRequestAuthentication, null);
348+
}
349+
350+
OAuth2Authorization authorization = this.authorizationService
351+
.findByToken(pushedAuthorizationRequestUri.getState(), STATE_TOKEN_TYPE);
352+
if (authorization == null) {
353+
throwError(OAuth2ErrorCodes.INVALID_REQUEST, "request_uri", authorizationCodeRequestAuthentication, null);
354+
}
355+
356+
OAuth2AuthorizationRequest authorizationRequest = authorization
357+
.getAttribute(OAuth2AuthorizationRequest.class.getName());
358+
359+
return new OAuth2AuthorizationCodeRequestAuthenticationToken(
360+
authorizationCodeRequestAuthentication.getAuthorizationUri(),
361+
authorizationCodeRequestAuthentication.getClientId(),
362+
(Authentication) authorizationCodeRequestAuthentication.getPrincipal(),
363+
authorizationRequest.getRedirectUri(), authorizationRequest.getState(),
364+
authorizationRequest.getScopes(), authorizationRequest.getAdditionalParameters());
365+
}
366+
350367
private static boolean isAuthorizationConsentRequired(
351368
OAuth2AuthorizationCodeRequestAuthenticationContext authenticationContext) {
352369
if (!authenticationContext.getRegisteredClient().getClientSettings().isRequireAuthorizationConsent()) {
@@ -457,23 +474,4 @@ private static String resolveRedirectUri(
457474
return null;
458475
}
459476

460-
/*
461-
* The values defined for the "prompt" parameter for the OpenID Connect 1.0
462-
* Authentication Request.
463-
*/
464-
private static final class OidcPrompts {
465-
466-
private static final String NONE = "none";
467-
468-
private static final String LOGIN = "login";
469-
470-
private static final String CONSENT = "consent";
471-
472-
private static final String SELECT_ACCOUNT = "select_account";
473-
474-
private OidcPrompts() {
475-
}
476-
477-
}
478-
479477
}

0 commit comments

Comments
 (0)