Skip to content

Commit a7abc94

Browse files
committed
Add generic request validator for refresh token
Signed-off-by: Andrey Litvitski <[email protected]>
1 parent 727f0e2 commit a7abc94

File tree

3 files changed

+232
-50
lines changed

3 files changed

+232
-50
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright 2004-present 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+
17+
package org.springframework.security.oauth2.server.authorization.authentication;
18+
19+
import java.util.Collections;
20+
import java.util.HashMap;
21+
import java.util.Map;
22+
import java.util.function.Consumer;
23+
24+
import org.jspecify.annotations.Nullable;
25+
26+
import org.springframework.security.oauth2.jwt.Jwt;
27+
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
28+
import org.springframework.util.Assert;
29+
30+
/**
31+
* An {@link OAuth2AuthenticationContext} that holds an
32+
* {@link OAuth2RefreshTokenAuthenticationToken} and additional information and is used
33+
* when validating the OAuth 2.0 Refresh Token Grant Request.
34+
* <p>
35+
* This context provides access to the current {@link OAuth2Authorization},
36+
* {@link OAuth2ClientAuthenticationToken}, and optionally a DPoP {@link Jwt} proof.
37+
* </p>
38+
*
39+
* @author Andrey Litvitski
40+
* @since 7.0.0
41+
* @see OAuth2AuthenticationContext
42+
* @see OAuth2RefreshTokenAuthenticationProvider#setAuthenticationValidator(Consumer)
43+
*/
44+
public final class OAuth2RefreshTokenAuthenticationContext implements OAuth2AuthenticationContext {
45+
46+
private final Map<Object, Object> context;
47+
48+
private OAuth2RefreshTokenAuthenticationContext(Map<Object, Object> context) {
49+
this.context = Collections.unmodifiableMap(new HashMap<>(context));
50+
}
51+
52+
@SuppressWarnings("unchecked")
53+
@Nullable
54+
@Override
55+
public <V> V get(Object key) {
56+
return hasKey(key) ? (V) this.context.get(key) : null;
57+
}
58+
59+
@Override
60+
public boolean hasKey(Object key) {
61+
Assert.notNull(key, "key cannot be null");
62+
return this.context.containsKey(key);
63+
}
64+
65+
public OAuth2Authorization getAuthorization() {
66+
return get(OAuth2Authorization.class);
67+
}
68+
69+
public OAuth2ClientAuthenticationToken getClientPrincipal() {
70+
return get(OAuth2ClientAuthenticationToken.class);
71+
}
72+
73+
@Nullable public Jwt getDPoPProof() {
74+
return get(Jwt.class);
75+
}
76+
77+
public static Builder with(OAuth2RefreshTokenAuthenticationToken authentication) {
78+
return new Builder(authentication);
79+
}
80+
81+
public static final class Builder extends AbstractBuilder<OAuth2RefreshTokenAuthenticationContext, Builder> {
82+
83+
private Builder(OAuth2RefreshTokenAuthenticationToken authentication) {
84+
super(authentication);
85+
}
86+
87+
public Builder authorization(OAuth2Authorization authorization) {
88+
return put(OAuth2Authorization.class, authorization);
89+
}
90+
91+
public Builder clientPrincipal(OAuth2ClientAuthenticationToken clientPrincipal) {
92+
return put(OAuth2ClientAuthenticationToken.class, clientPrincipal);
93+
}
94+
95+
public Builder dPoPProof(@Nullable Jwt dPoPProof) {
96+
return put(Jwt.class, dPoPProof);
97+
}
98+
99+
@Override
100+
public OAuth2RefreshTokenAuthenticationContext build() {
101+
Assert.notNull(get(OAuth2Authorization.class), "authorization cannot be null");
102+
Assert.notNull(get(OAuth2ClientAuthenticationToken.class), "clientPrincipal cannot be null");
103+
return new OAuth2RefreshTokenAuthenticationContext(getContext());
104+
}
105+
106+
}
107+
108+
}

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

Lines changed: 21 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
import java.util.HashMap;
2222
import java.util.Map;
2323
import java.util.Set;
24+
import java.util.function.Consumer;
2425

25-
import com.nimbusds.jose.jwk.JWK;
2626
import org.apache.commons.logging.Log;
2727
import org.apache.commons.logging.LogFactory;
2828

@@ -31,8 +31,6 @@
3131
import org.springframework.security.core.Authentication;
3232
import org.springframework.security.core.AuthenticationException;
3333
import org.springframework.security.oauth2.core.AuthorizationGrantType;
34-
import org.springframework.security.oauth2.core.ClaimAccessor;
35-
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
3634
import org.springframework.security.oauth2.core.OAuth2AccessToken;
3735
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
3836
import org.springframework.security.oauth2.core.OAuth2Error;
@@ -52,14 +50,14 @@
5250
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
5351
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
5452
import org.springframework.util.Assert;
55-
import org.springframework.util.CollectionUtils;
5653

5754
/**
5855
* An {@link AuthenticationProvider} implementation for the OAuth 2.0 Refresh Token Grant.
5956
*
6057
* @author Alexey Nesterov
6158
* @author Joe Grandja
6259
* @author Anoop Garlapati
60+
* @author Andrey Litvitski
6361
* @since 7.0
6462
* @see OAuth2RefreshTokenAuthenticationToken
6563
* @see OAuth2AccessTokenAuthenticationToken
@@ -84,6 +82,8 @@ public final class OAuth2RefreshTokenAuthenticationProvider implements Authentic
8482

8583
private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
8684

85+
private Consumer<OAuth2RefreshTokenAuthenticationContext> authenticationValidator = new OAuth2RefreshTokenAuthenticationValidator();
86+
8787
/**
8888
* Constructs an {@code OAuth2RefreshTokenAuthenticationProvider} using the provided
8989
* parameters.
@@ -164,13 +164,14 @@ public Authentication authenticate(Authentication authentication) throws Authent
164164
// Verify the DPoP Proof (if available)
165165
Jwt dPoPProof = DPoPProofVerifier.verifyIfAvailable(refreshTokenAuthentication);
166166

167-
if (dPoPProof != null
168-
&& clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) {
169-
// For public clients, verify the DPoP Proof public key is same as (current)
170-
// access token public key binding
171-
Map<String, Object> accessTokenClaims = authorization.getAccessToken().getClaims();
172-
verifyDPoPProofPublicKey(dPoPProof, () -> accessTokenClaims);
173-
}
167+
OAuth2RefreshTokenAuthenticationContext context = OAuth2RefreshTokenAuthenticationContext
168+
.with(refreshTokenAuthentication)
169+
.authorization(authorization)
170+
.clientPrincipal(clientPrincipal)
171+
.dPoPProof(dPoPProof)
172+
.build();
173+
174+
this.authenticationValidator.accept(context);
174175

175176
if (this.logger.isTraceEnabled()) {
176177
this.logger.trace("Validated token request parameters");
@@ -292,45 +293,15 @@ public boolean supports(Class<?> authentication) {
292293
return OAuth2RefreshTokenAuthenticationToken.class.isAssignableFrom(authentication);
293294
}
294295

295-
private static void verifyDPoPProofPublicKey(Jwt dPoPProof, ClaimAccessor accessTokenClaims) {
296-
JWK jwk = null;
297-
@SuppressWarnings("unchecked")
298-
Map<String, Object> jwkJson = (Map<String, Object>) dPoPProof.getHeaders().get("jwk");
299-
try {
300-
jwk = JWK.parse(jwkJson);
301-
}
302-
catch (Exception ignored) {
303-
}
304-
if (jwk == null) {
305-
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF,
306-
"jwk header is missing or invalid.", null);
307-
throw new OAuth2AuthenticationException(error);
308-
}
309-
310-
String jwkThumbprint;
311-
try {
312-
jwkThumbprint = jwk.computeThumbprint().toString();
313-
}
314-
catch (Exception ex) {
315-
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF,
316-
"Failed to compute SHA-256 Thumbprint for jwk.", null);
317-
throw new OAuth2AuthenticationException(error);
318-
}
319-
320-
String jwkThumbprintClaim = null;
321-
Map<String, Object> confirmationMethodClaim = accessTokenClaims.getClaimAsMap("cnf");
322-
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
323-
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
324-
}
325-
if (jwkThumbprintClaim == null) {
326-
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, "jkt claim is missing.", null);
327-
throw new OAuth2AuthenticationException(error);
328-
}
329-
330-
if (!jwkThumbprint.equals(jwkThumbprintClaim)) {
331-
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, "jwk header is invalid.", null);
332-
throw new OAuth2AuthenticationException(error);
333-
}
296+
/**
297+
* Sets the {@code Consumer} responsible for validating the OAuth 2.0 Refresh Token
298+
* Grant Request using the provided {@link OAuth2RefreshTokenAuthenticationContext}.
299+
* <p>
300+
* The default validator performs DPoP proof verification if present.
301+
*/
302+
public void setAuthenticationValidator(Consumer<OAuth2RefreshTokenAuthenticationContext> authenticationValidator) {
303+
Assert.notNull(authenticationValidator, "authenticationValidator cannot be null");
304+
this.authenticationValidator = authenticationValidator;
334305
}
335306

336307
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*
2+
* Copyright 2004-present 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+
17+
package org.springframework.security.oauth2.server.authorization.authentication;
18+
19+
import java.util.Map;
20+
import java.util.function.Consumer;
21+
22+
import com.nimbusds.jose.jwk.JWK;
23+
24+
import org.springframework.security.oauth2.core.ClaimAccessor;
25+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
26+
import org.springframework.security.oauth2.core.OAuth2Error;
27+
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
28+
import org.springframework.util.CollectionUtils;
29+
30+
/**
31+
* A {@code Consumer} that validates an {@link OAuth2RefreshTokenAuthenticationContext}
32+
* and acts as the default
33+
* {@link OAuth2RefreshTokenAuthenticationProvider#setAuthenticationValidator(Consumer)
34+
* authentication validator} for the Refresh Token grant.
35+
* <p>
36+
* The default implementation validates a DPoP proof if present and throws
37+
* {@link OAuth2AuthenticationException} on failure.
38+
* </p>
39+
*
40+
* @author Andrey Litvitski
41+
* @since 7.0.0
42+
* @see OAuth2RefreshTokenAuthenticationContext
43+
* @see OAuth2RefreshTokenAuthenticationProvider#setAuthenticationValidator(Consumer)
44+
*/
45+
public final class OAuth2RefreshTokenAuthenticationValidator
46+
implements Consumer<OAuth2RefreshTokenAuthenticationContext> {
47+
48+
public static final Consumer<OAuth2RefreshTokenAuthenticationContext> DEFAULT_VALIDATOR = OAuth2RefreshTokenAuthenticationValidator::validateDefault;
49+
50+
private final Consumer<OAuth2RefreshTokenAuthenticationContext> authenticationValidator = DEFAULT_VALIDATOR;
51+
52+
@Override
53+
public void accept(OAuth2RefreshTokenAuthenticationContext context) {
54+
this.authenticationValidator.accept(context);
55+
}
56+
57+
private static void validateDefault(OAuth2RefreshTokenAuthenticationContext context) {
58+
if (context.getDPoPProof() == null) {
59+
return;
60+
}
61+
JWK jwk = null;
62+
@SuppressWarnings("unchecked")
63+
Map<String, Object> jwkJson = (Map<String, Object>) context.getDPoPProof().getHeaders().get("jwk");
64+
try {
65+
jwk = JWK.parse(jwkJson);
66+
}
67+
catch (Exception ignored) {
68+
}
69+
if (jwk == null) {
70+
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF,
71+
"jwk header is missing or invalid.", null);
72+
throw new OAuth2AuthenticationException(error);
73+
}
74+
75+
String jwkThumbprint;
76+
try {
77+
jwkThumbprint = jwk.computeThumbprint().toString();
78+
}
79+
catch (Exception ex) {
80+
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF,
81+
"Failed to compute SHA-256 Thumbprint for jwk.", null);
82+
throw new OAuth2AuthenticationException(error);
83+
}
84+
85+
String jwkThumbprintClaim = null;
86+
Map<String, Object> accessTokenClaimsMap = context.getAuthorization().getAccessToken().getClaims();
87+
ClaimAccessor accessTokenClaims = () -> accessTokenClaimsMap;
88+
Map<String, Object> confirmationMethodClaim = accessTokenClaims.getClaimAsMap("cnf");
89+
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
90+
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
91+
}
92+
if (jwkThumbprintClaim == null) {
93+
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, "jkt claim is missing.", null);
94+
throw new OAuth2AuthenticationException(error);
95+
}
96+
97+
if (!jwkThumbprint.equals(jwkThumbprintClaim)) {
98+
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, "jwk header is invalid.", null);
99+
throw new OAuth2AuthenticationException(error);
100+
}
101+
}
102+
103+
}

0 commit comments

Comments
 (0)