Skip to content

Commit 48fd6ab

Browse files
committed
Verify DPoP Proof public key during refresh_token grant for public clients
Issue gh-1813 Closes gh-1949
1 parent dc167bf commit 48fd6ab

File tree

2 files changed

+174
-0
lines changed

2 files changed

+174
-0
lines changed

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

+66
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@
1515
*/
1616
package org.springframework.security.oauth2.server.authorization.authentication;
1717

18+
import java.security.MessageDigest;
1819
import java.security.Principal;
20+
import java.security.PublicKey;
21+
import java.util.Base64;
1922
import java.util.Collections;
2023
import java.util.HashMap;
2124
import java.util.Map;
2225
import java.util.Set;
2326

27+
import com.nimbusds.jose.jwk.AsymmetricJWK;
28+
import com.nimbusds.jose.jwk.JWK;
2429
import org.apache.commons.logging.Log;
2530
import org.apache.commons.logging.LogFactory;
2631

@@ -29,6 +34,8 @@
2934
import org.springframework.security.core.Authentication;
3035
import org.springframework.security.core.AuthenticationException;
3136
import org.springframework.security.oauth2.core.AuthorizationGrantType;
37+
import org.springframework.security.oauth2.core.ClaimAccessor;
38+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
3239
import org.springframework.security.oauth2.core.OAuth2AccessToken;
3340
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
3441
import org.springframework.security.oauth2.core.OAuth2Error;
@@ -48,6 +55,7 @@
4855
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
4956
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
5057
import org.springframework.util.Assert;
58+
import org.springframework.util.CollectionUtils;
5159

5260
/**
5361
* An {@link AuthenticationProvider} implementation for the OAuth 2.0 Refresh Token Grant.
@@ -160,6 +168,14 @@ public Authentication authenticate(Authentication authentication) throws Authent
160168
// Verify the DPoP Proof (if available)
161169
Jwt dPoPProof = DPoPProofVerifier.verifyIfAvailable(refreshTokenAuthentication);
162170

171+
if (dPoPProof != null
172+
& clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) {
173+
// For public clients, verify the DPoP Proof public key is same as (current)
174+
// access token public key binding
175+
Map<String, Object> accessTokenClaims = authorization.getAccessToken().getClaims();
176+
verifyDPoPProofPublicKey(dPoPProof, () -> accessTokenClaims);
177+
}
178+
163179
if (this.logger.isTraceEnabled()) {
164180
this.logger.trace("Validated token request parameters");
165181
}
@@ -275,4 +291,54 @@ public boolean supports(Class<?> authentication) {
275291
return OAuth2RefreshTokenAuthenticationToken.class.isAssignableFrom(authentication);
276292
}
277293

294+
private static void verifyDPoPProofPublicKey(Jwt dPoPProof, ClaimAccessor accessTokenClaims) {
295+
PublicKey publicKey = null;
296+
@SuppressWarnings("unchecked")
297+
Map<String, Object> jwkJson = (Map<String, Object>) dPoPProof.getHeaders().get("jwk");
298+
try {
299+
JWK jwk = JWK.parse(jwkJson);
300+
if (jwk instanceof AsymmetricJWK) {
301+
publicKey = ((AsymmetricJWK) jwk).toPublicKey();
302+
}
303+
}
304+
catch (Exception ignored) {
305+
}
306+
if (publicKey == null) {
307+
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF,
308+
"jwk header is missing or invalid.", null);
309+
throw new OAuth2AuthenticationException(error);
310+
}
311+
312+
String jwkThumbprint;
313+
try {
314+
jwkThumbprint = computeSHA256(publicKey);
315+
}
316+
catch (Exception ex) {
317+
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF,
318+
"Failed to compute SHA-256 Thumbprint for jwk.", null);
319+
throw new OAuth2AuthenticationException(error);
320+
}
321+
322+
String jwkThumbprintClaim = null;
323+
Map<String, Object> confirmationMethodClaim = accessTokenClaims.getClaimAsMap("cnf");
324+
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
325+
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
326+
}
327+
if (jwkThumbprintClaim == null) {
328+
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, "jkt claim is missing.", null);
329+
throw new OAuth2AuthenticationException(error);
330+
}
331+
332+
if (!jwkThumbprint.equals(jwkThumbprintClaim)) {
333+
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, "jwk header is invalid.", null);
334+
throw new OAuth2AuthenticationException(error);
335+
}
336+
}
337+
338+
private static String computeSHA256(PublicKey publicKey) throws Exception {
339+
MessageDigest md = MessageDigest.getInstance("SHA-256");
340+
byte[] digest = md.digest(publicKey.getEncoded());
341+
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
342+
}
343+
278344
}

oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2RefreshTokenGrantTests.java

+108
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717

1818
import java.net.URLEncoder;
1919
import java.nio.charset.StandardCharsets;
20+
import java.security.MessageDigest;
2021
import java.security.Principal;
22+
import java.security.PublicKey;
2123
import java.time.Instant;
2224
import java.util.Base64;
25+
import java.util.HashMap;
2326
import java.util.HashSet;
2427
import java.util.List;
2528
import java.util.Map;
@@ -279,6 +282,105 @@ public void requestWhenRefreshTokenRequestWithPublicClientThenReturnAccessTokenR
279282
.andExpect(jsonPath("$.scope").isNotEmpty());
280283
}
281284

285+
@Test
286+
public void requestWhenRefreshTokenRequestWithPublicClientAndDPoPProofThenReturnDPoPBoundAccessToken()
287+
throws Exception {
288+
this.spring.register(AuthorizationServerConfigurationWithPublicClientAuthentication.class).autowire();
289+
290+
RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
291+
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
292+
.build();
293+
this.registeredClientRepository.save(registeredClient);
294+
295+
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.DPOP,
296+
"dpop-bound-access-token", Instant.now(), Instant.now().plusSeconds(300));
297+
Map<String, Object> accessTokenClaims = new HashMap<>();
298+
PublicKey publicKey = TestJwks.DEFAULT_EC_JWK.toPublicKey();
299+
Map<String, Object> cnfClaim = new HashMap<>();
300+
cnfClaim.put("jkt", computeSHA256(publicKey));
301+
accessTokenClaims.put("cnf", cnfClaim);
302+
OAuth2Authorization authorization = TestOAuth2Authorizations
303+
.authorization(registeredClient, accessToken, accessTokenClaims)
304+
.build();
305+
this.authorizationService.save(authorization);
306+
307+
String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
308+
String dPoPProof = generateDPoPProof(tokenEndpointUri);
309+
310+
this.mvc
311+
.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
312+
.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
313+
.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
314+
.andExpect(status().isOk())
315+
.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
316+
317+
authorization = this.authorizationService.findById(authorization.getId());
318+
assertThat(authorization.getAccessToken().getClaims()).containsKey("cnf");
319+
@SuppressWarnings("unchecked")
320+
Map<String, Object> cnfClaims = (Map<String, Object>) authorization.getAccessToken().getClaims().get("cnf");
321+
assertThat(cnfClaims).containsKey("jkt");
322+
}
323+
324+
@Test
325+
public void requestWhenRefreshTokenRequestWithPublicClientAndDPoPProofAndAccessTokenNotBoundThenBadRequest()
326+
throws Exception {
327+
this.spring.register(AuthorizationServerConfigurationWithPublicClientAuthentication.class).autowire();
328+
329+
RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
330+
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
331+
.build();
332+
this.registeredClientRepository.save(registeredClient);
333+
334+
OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
335+
this.authorizationService.save(authorization);
336+
337+
String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
338+
String dPoPProof = generateDPoPProof(tokenEndpointUri);
339+
340+
this.mvc
341+
.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
342+
.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
343+
.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
344+
.andExpect(status().isBadRequest())
345+
.andExpect(jsonPath("$.error").value(OAuth2ErrorCodes.INVALID_DPOP_PROOF))
346+
.andExpect(jsonPath("$.error_description").value("jkt claim is missing."));
347+
}
348+
349+
@Test
350+
public void requestWhenRefreshTokenRequestWithPublicClientAndDPoPProofAndDifferentPublicKeyThenBadRequest()
351+
throws Exception {
352+
this.spring.register(AuthorizationServerConfigurationWithPublicClientAuthentication.class).autowire();
353+
354+
RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
355+
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
356+
.build();
357+
this.registeredClientRepository.save(registeredClient);
358+
359+
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.DPOP,
360+
"dpop-bound-access-token", Instant.now(), Instant.now().plusSeconds(300));
361+
Map<String, Object> accessTokenClaims = new HashMap<>();
362+
// Bind access token to different public key
363+
PublicKey publicKey = TestJwks.DEFAULT_RSA_JWK.toPublicKey();
364+
Map<String, Object> cnfClaim = new HashMap<>();
365+
cnfClaim.put("jkt", computeSHA256(publicKey));
366+
accessTokenClaims.put("cnf", cnfClaim);
367+
OAuth2Authorization authorization = TestOAuth2Authorizations
368+
.authorization(registeredClient, accessToken, accessTokenClaims)
369+
.build();
370+
this.authorizationService.save(authorization);
371+
372+
String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
373+
String dPoPProof = generateDPoPProof(tokenEndpointUri);
374+
375+
this.mvc
376+
.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
377+
.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
378+
.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
379+
.andExpect(status().isBadRequest())
380+
.andExpect(jsonPath("$.error").value(OAuth2ErrorCodes.INVALID_DPOP_PROOF))
381+
.andExpect(jsonPath("$.error_description").value("jwk header is invalid."));
382+
}
383+
282384
@Test
283385
public void requestWhenRefreshTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
284386
this.spring.register(AuthorizationServerConfiguration.class).autowire();
@@ -327,6 +429,12 @@ private static String generateDPoPProof(String tokenEndpointUri) {
327429
return jwt.getTokenValue();
328430
}
329431

432+
private static String computeSHA256(PublicKey publicKey) throws Exception {
433+
MessageDigest md = MessageDigest.getInstance("SHA-256");
434+
byte[] digest = md.digest(publicKey.getEncoded());
435+
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
436+
}
437+
330438
private static MultiValueMap<String, String> getRefreshTokenRequestParameters(OAuth2Authorization authorization) {
331439
MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
332440
parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.REFRESH_TOKEN.getValue());

0 commit comments

Comments
 (0)