浏览代码

Add support for OAuth 2.0 Demonstrating Proof of Possession (DPoP)

Closes gh-1813
Joe Grandja 6 月之前
父节点
当前提交
779d87a279
共有 30 个文件被更改,包括 879 次插入57 次删除
  1. 5 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java
  2. 66 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/DPoPProofVerifier.java
  3. 13 3
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthenticationProviderUtils.java
  4. 7 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java
  5. 11 4
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProvider.java
  6. 8 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java
  7. 7 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java
  8. 8 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java
  9. 55 4
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DefaultOAuth2TokenCustomizers.java
  10. 7 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java
  11. 8 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/OAuth2AccessTokenGenerator.java
  12. 7 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/OAuth2TokenContext.java
  13. 4 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeAuthenticationConverter.java
  14. 4 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2ClientCredentialsAuthenticationConverter.java
  15. 4 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java
  16. 19 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java
  17. 4 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2RefreshTokenAuthenticationConverter.java
  18. 4 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java
  19. 39 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java
  20. 43 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java
  21. 49 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java
  22. 42 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java
  23. 49 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java
  24. 59 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java
  25. 55 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java
  26. 90 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java
  27. 62 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2RefreshTokenGrantTests.java
  28. 89 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java
  29. 49 14
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java
  30. 12 3
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java

+ 5 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.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.
@@ -512,6 +512,10 @@ public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationServic
 				if (OAuth2AccessToken.TokenType.BEARER.getValue().equalsIgnoreCase(rs.getString("access_token_type"))) {
 					tokenType = OAuth2AccessToken.TokenType.BEARER;
 				}
+				else if (OAuth2AccessToken.TokenType.DPOP.getValue()
+					.equalsIgnoreCase(rs.getString("access_token_type"))) {
+					tokenType = OAuth2AccessToken.TokenType.DPOP;
+				}
 
 				Set<String> scopes = Collections.emptySet();
 				String accessTokenScopes = rs.getString("access_token_scopes");

+ 66 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/DPoPProofVerifier.java

@@ -0,0 +1,66 @@
+/*
+ * 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 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.jwt.DPoPProofContext;
+import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
+import org.springframework.util.StringUtils;
+
+/**
+ * @author Joe Grandja
+ * @since 1.5
+ */
+final class DPoPProofVerifier {
+
+	private static final JwtDecoderFactory<DPoPProofContext> dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory();
+
+	private DPoPProofVerifier() {
+	}
+
+	static Jwt verifyIfAvailable(OAuth2AuthorizationGrantAuthenticationToken authorizationGrantAuthentication) {
+		String dPoPProof = (String) authorizationGrantAuthentication.getAdditionalParameters().get("dpop_proof");
+		if (!StringUtils.hasText(dPoPProof)) {
+			return null;
+		}
+
+		String method = (String) authorizationGrantAuthentication.getAdditionalParameters().get("dpop_method");
+		String targetUri = (String) authorizationGrantAuthentication.getAdditionalParameters().get("dpop_target_uri");
+
+		Jwt dPoPProofJwt;
+		try {
+			// @formatter:off
+			DPoPProofContext dPoPProofContext = DPoPProofContext.withDPoPProof(dPoPProof)
+					.method(method)
+					.targetUri(targetUri)
+					.build();
+			// @formatter:on
+			JwtDecoder dPoPProofVerifier = dPoPProofVerifierFactory.createDecoder(dPoPProofContext);
+			dPoPProofJwt = dPoPProofVerifier.decode(dPoPProof);
+		}
+		catch (Exception ex) {
+			throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF), ex);
+		}
+
+		return dPoPProofJwt;
+	}
+
+}

+ 13 - 3
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthenticationProviderUtils.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.
@@ -15,6 +15,8 @@
  */
 package org.springframework.security.oauth2.server.authorization.authentication;
 
+import java.util.Map;
+
 import org.springframework.security.authentication.AuthenticationProvider;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.oauth2.core.ClaimAccessor;
@@ -25,6 +27,7 @@ import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
 import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.util.CollectionUtils;
 
 /**
  * Utility methods for the OAuth 2.0 {@link AuthenticationProvider}'s.
@@ -51,8 +54,15 @@ final class OAuth2AuthenticationProviderUtils {
 	static <T extends OAuth2Token> OAuth2AccessToken accessToken(OAuth2Authorization.Builder builder, T token,
 			OAuth2TokenContext accessTokenContext) {
 
-		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, token.getTokenValue(),
-				token.getIssuedAt(), token.getExpiresAt(), accessTokenContext.getAuthorizedScopes());
+		OAuth2AccessToken.TokenType tokenType = OAuth2AccessToken.TokenType.BEARER;
+		if (token instanceof ClaimAccessor claimAccessor) {
+			Map<String, Object> cnfClaims = claimAccessor.getClaimAsMap("cnf");
+			if (!CollectionUtils.isEmpty(cnfClaims) && cnfClaims.containsKey("jkt")) {
+				tokenType = OAuth2AccessToken.TokenType.DPOP;
+			}
+		}
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(tokenType, token.getTokenValue(), token.getIssuedAt(),
+				token.getExpiresAt(), accessTokenContext.getAuthorizedScopes());
 		OAuth2TokenFormat accessTokenFormat = accessTokenContext.getRegisteredClient()
 			.getTokenSettings()
 			.getAccessTokenFormat();

+ 7 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.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.
@@ -185,6 +185,9 @@ public final class OAuth2AuthorizationCodeAuthenticationProvider implements Auth
 			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT);
 		}
 
+		// Verify the DPoP Proof (if available)
+		Jwt dPoPProof = DPoPProofVerifier.verifyIfAvailable(authorizationCodeAuthentication);
+
 		if (this.logger.isTraceEnabled()) {
 			this.logger.trace("Validated token request parameters");
 		}
@@ -201,6 +204,9 @@ public final class OAuth2AuthorizationCodeAuthenticationProvider implements Auth
 				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
 				.authorizationGrant(authorizationCodeAuthentication);
 		// @formatter:on
+		if (dPoPProof != null) {
+			tokenContextBuilder.put(OAuth2TokenContext.DPOP_PROOF_KEY, dPoPProof);
+		}
 
 		OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.from(authorization);
 

+ 11 - 4
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProvider.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.
@@ -32,6 +32,7 @@ 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.OAuth2Token;
+import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
@@ -116,21 +117,27 @@ public final class OAuth2ClientCredentialsAuthenticationProvider implements Auth
 
 		Set<String> authorizedScopes = new LinkedHashSet<>(clientCredentialsAuthentication.getScopes());
 
+		// Verify the DPoP Proof (if available)
+		Jwt dPoPProof = DPoPProofVerifier.verifyIfAvailable(clientCredentialsAuthentication);
+
 		if (this.logger.isTraceEnabled()) {
 			this.logger.trace("Validated token request parameters");
 		}
 
 		// @formatter:off
-		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+		DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
 				.registeredClient(registeredClient)
 				.principal(clientPrincipal)
 				.authorizationServerContext(AuthorizationServerContextHolder.getContext())
 				.authorizedScopes(authorizedScopes)
 				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
 				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
-				.authorizationGrant(clientCredentialsAuthentication)
-				.build();
+				.authorizationGrant(clientCredentialsAuthentication);
 		// @formatter:on
+		if (dPoPProof != null) {
+			tokenContextBuilder.put(OAuth2TokenContext.DPOP_PROOF_KEY, dPoPProof);
+		}
+		OAuth2TokenContext tokenContext = tokenContextBuilder.build();
 
 		OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext);
 		if (generatedAccessToken == null) {

+ 8 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.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.
@@ -34,6 +34,7 @@ import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.OAuth2UserCode;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
@@ -182,6 +183,9 @@ public final class OAuth2DeviceCodeAuthenticationProvider implements Authenticat
 			throw new OAuth2AuthenticationException(error);
 		}
 
+		// Verify the DPoP Proof (if available)
+		Jwt dPoPProof = DPoPProofVerifier.verifyIfAvailable(deviceCodeAuthentication);
+
 		if (this.logger.isTraceEnabled()) {
 			this.logger.trace("Validated device token request parameters");
 		}
@@ -196,6 +200,9 @@ public final class OAuth2DeviceCodeAuthenticationProvider implements Authenticat
 				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
 				.authorizationGrant(deviceCodeAuthentication);
 		// @formatter:on
+		if (dPoPProof != null) {
+			tokenContextBuilder.put(OAuth2TokenContext.DPOP_PROOF_KEY, dPoPProof);
+		}
 
 		// @formatter:off
 		OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.from(authorization)

+ 7 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.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.
@@ -157,6 +157,9 @@ public final class OAuth2RefreshTokenAuthenticationProvider implements Authentic
 			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_SCOPE);
 		}
 
+		// Verify the DPoP Proof (if available)
+		Jwt dPoPProof = DPoPProofVerifier.verifyIfAvailable(refreshTokenAuthentication);
+
 		if (this.logger.isTraceEnabled()) {
 			this.logger.trace("Validated token request parameters");
 		}
@@ -175,6 +178,9 @@ public final class OAuth2RefreshTokenAuthenticationProvider implements Authentic
 				.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
 				.authorizationGrant(refreshTokenAuthentication);
 		// @formatter:on
+		if (dPoPProof != null) {
+			tokenContextBuilder.put(OAuth2TokenContext.DPOP_PROOF_KEY, dPoPProof);
+		}
 
 		OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.from(authorization);
 

+ 8 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.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.
@@ -38,6 +38,7 @@ import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
@@ -203,6 +204,9 @@ public final class OAuth2TokenExchangeAuthenticationProvider implements Authenti
 			authorizedScopes = validateRequestedScopes(registeredClient, subjectAuthorization.getAuthorizedScopes());
 		}
 
+		// Verify the DPoP Proof (if available)
+		Jwt dPoPProof = DPoPProofVerifier.verifyIfAvailable(tokenExchangeAuthentication);
+
 		if (this.logger.isTraceEnabled()) {
 			this.logger.trace("Validated token request parameters");
 		}
@@ -220,6 +224,9 @@ public final class OAuth2TokenExchangeAuthenticationProvider implements Authenti
 				.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
 				.authorizationGrant(tokenExchangeAuthentication);
 		// @formatter:on
+		if (dPoPProof != null) {
+			tokenContextBuilder.put(OAuth2TokenContext.DPOP_PROOF_KEY, dPoPProof);
+		}
 
 		// ----- Access token -----
 		OAuth2TokenContext tokenContext = tokenContextBuilder.build();

+ 55 - 4
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DefaultOAuth2TokenCustomizers.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.
@@ -16,6 +16,7 @@
 package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
 
 import java.security.MessageDigest;
+import java.security.PublicKey;
 import java.security.cert.X509Certificate;
 import java.util.Base64;
 import java.util.Collections;
@@ -23,10 +24,14 @@ import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
+import com.nimbusds.jose.jwk.AsymmetricJWK;
+import com.nimbusds.jose.jwk.JWK;
+
 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.jwt.Jwt;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeActor;
@@ -36,6 +41,7 @@ import org.springframework.security.oauth2.server.authorization.token.OAuth2Toke
 import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsContext;
 import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
 import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.util.CollectionUtils;
 
 /**
  * @author Joe Grandja
@@ -56,6 +62,8 @@ final class DefaultOAuth2TokenCustomizers {
 	}
 
 	private static void customize(OAuth2TokenContext tokenContext, Map<String, Object> claims) {
+		Map<String, Object> cnfClaims = null;
+
 		// Add 'cnf' claim for Mutual-TLS Client Certificate-Bound Access Tokens
 		if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenContext.getTokenType())
 				&& tokenContext.getAuthorizationGrant() != null && tokenContext.getAuthorizationGrant()
@@ -69,9 +77,8 @@ final class DefaultOAuth2TokenCustomizers {
 				X509Certificate[] clientCertificateChain = (X509Certificate[]) clientAuthentication.getCredentials();
 				try {
 					String sha256Thumbprint = computeSHA256Thumbprint(clientCertificateChain[0]);
-					Map<String, Object> x5tClaim = new HashMap<>();
-					x5tClaim.put("x5t#S256", sha256Thumbprint);
-					claims.put("cnf", x5tClaim);
+					cnfClaims = new HashMap<>();
+					cnfClaims.put("x5t#S256", sha256Thumbprint);
 				}
 				catch (Exception ex) {
 					OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
@@ -81,6 +88,44 @@ final class DefaultOAuth2TokenCustomizers {
 			}
 		}
 
+		// Add 'cnf' claim for OAuth 2.0 Demonstrating Proof of Possession (DPoP)
+		Jwt dPoPProofJwt = tokenContext.get(OAuth2TokenContext.DPOP_PROOF_KEY);
+		if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenContext.getTokenType()) && dPoPProofJwt != null) {
+			PublicKey publicKey = null;
+			@SuppressWarnings("unchecked")
+			Map<String, Object> jwkJson = (Map<String, Object>) dPoPProofJwt.getHeaders().get("jwk");
+			try {
+				JWK jwk = JWK.parse(jwkJson);
+				if (jwk instanceof AsymmetricJWK) {
+					publicKey = ((AsymmetricJWK) jwk).toPublicKey();
+				}
+			}
+			catch (Exception ignored) {
+			}
+			if (publicKey == null) {
+				OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF,
+						"jwk header is missing or invalid.", null);
+				throw new OAuth2AuthenticationException(error);
+			}
+
+			try {
+				String sha256Thumbprint = computeSHA256Thumbprint(publicKey);
+				if (cnfClaims == null) {
+					cnfClaims = new HashMap<>();
+				}
+				cnfClaims.put("jkt", sha256Thumbprint);
+			}
+			catch (Exception ex) {
+				OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
+						"Failed to compute SHA-256 Thumbprint for DPoP Proof PublicKey.", null);
+				throw new OAuth2AuthenticationException(error, ex);
+			}
+		}
+
+		if (!CollectionUtils.isEmpty(cnfClaims)) {
+			claims.put("cnf", cnfClaims);
+		}
+
 		// Add 'act' claim for delegation use case of Token Exchange Grant.
 		// If more than one actor is present, we create a chain of delegation by nesting
 		// "act" claims.
@@ -104,4 +149,10 @@ final class DefaultOAuth2TokenCustomizers {
 		return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
 	}
 
+	private static String computeSHA256Thumbprint(PublicKey publicKey) throws Exception {
+		MessageDigest md = MessageDigest.getInstance("SHA-256");
+		byte[] digest = md.digest(publicKey.getEncoded());
+		return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+	}
+
 }

+ 7 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.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.
@@ -175,6 +175,12 @@ public final class JwtGenerator implements OAuth2TokenGenerator<Jwt> {
 					jwtContextBuilder.put(SessionInformation.class, sessionInformation);
 				}
 			}
+			if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
+				Jwt dPoPProofJwt = context.get(OAuth2TokenContext.DPOP_PROOF_KEY);
+				if (dPoPProofJwt != null) {
+					jwtContextBuilder.put(OAuth2TokenContext.DPOP_PROOF_KEY, dPoPProofJwt);
+				}
+			}
 			// @formatter:on
 
 			JwtEncodingContext jwtContext = jwtContextBuilder.build();

+ 8 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/OAuth2AccessTokenGenerator.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.
@@ -28,6 +28,7 @@ import org.springframework.security.crypto.keygen.StringKeyGenerator;
 import org.springframework.security.oauth2.core.ClaimAccessor;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
@@ -105,6 +106,12 @@ public final class OAuth2AccessTokenGenerator implements OAuth2TokenGenerator<OA
 			if (context.getAuthorizationGrant() != null) {
 				accessTokenContextBuilder.authorizationGrant(context.getAuthorizationGrant());
 			}
+			if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
+				Jwt dPoPProofJwt = context.get(OAuth2TokenContext.DPOP_PROOF_KEY);
+				if (dPoPProofJwt != null) {
+					accessTokenContextBuilder.put(OAuth2TokenContext.DPOP_PROOF_KEY, dPoPProofJwt);
+				}
+			}
 			// @formatter:on
 
 			OAuth2TokenClaimsContext accessTokenContext = accessTokenContextBuilder.build();

+ 7 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/OAuth2TokenContext.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.
@@ -24,6 +24,7 @@ import java.util.function.Consumer;
 import org.springframework.lang.Nullable;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
@@ -43,6 +44,11 @@ import org.springframework.util.Assert;
  */
 public interface OAuth2TokenContext extends Context {
 
+	/**
+	 * @since 1.5
+	 */
+	String DPOP_PROOF_KEY = Jwt.class.getName().concat(".DPOP_PROOF");
+
 	/**
 	 * Returns the {@link RegisteredClient registered client}.
 	 * @return the {@link RegisteredClient}

+ 4 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2AuthorizationCodeAuthenticationConverter.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.
@@ -83,6 +83,9 @@ public final class OAuth2AuthorizationCodeAuthenticationConverter implements Aut
 			}
 		});
 
+		// Validate DPoP Proof HTTP Header (if available)
+		OAuth2EndpointUtils.validateAndAddDPoPParametersIfAvailable(request, additionalParameters);
+
 		return new OAuth2AuthorizationCodeAuthenticationToken(code, clientPrincipal, redirectUri, additionalParameters);
 	}
 

+ 4 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2ClientCredentialsAuthenticationConverter.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.
@@ -80,6 +80,9 @@ public final class OAuth2ClientCredentialsAuthenticationConverter implements Aut
 			}
 		});
 
+		// Validate DPoP Proof HTTP Header (if available)
+		OAuth2EndpointUtils.validateAndAddDPoPParametersIfAvailable(request, additionalParameters);
+
 		return new OAuth2ClientCredentialsAuthenticationToken(clientPrincipal, requestedScopes, additionalParameters);
 	}
 

+ 4 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.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.
@@ -74,6 +74,9 @@ public final class OAuth2DeviceCodeAuthenticationConverter implements Authentica
 			}
 		});
 
+		// Validate DPoP Proof HTTP Header (if available)
+		OAuth2EndpointUtils.validateAndAddDPoPParametersIfAvailable(request, additionalParameters);
+
 		return new OAuth2DeviceCodeAuthenticationToken(deviceCode, clientPrincipal, additionalParameters);
 	}
 

+ 19 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.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.
@@ -23,8 +23,10 @@ import java.util.Map;
 import jakarta.servlet.http.HttpServletRequest;
 
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
 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.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
 import org.springframework.util.Assert;
@@ -104,6 +106,22 @@ final class OAuth2EndpointUtils {
 				&& request.getParameter(PkceParameterNames.CODE_VERIFIER) != null;
 	}
 
+	static void validateAndAddDPoPParametersIfAvailable(HttpServletRequest request,
+			Map<String, Object> additionalParameters) {
+		final String dPoPProofHeaderName = OAuth2AccessToken.TokenType.DPOP.getValue();
+		String dPoPProof = request.getHeader(dPoPProofHeaderName);
+		if (StringUtils.hasText(dPoPProof)) {
+			if (Collections.list(request.getHeaders(dPoPProofHeaderName)).size() != 1) {
+				throwError(OAuth2ErrorCodes.INVALID_REQUEST, dPoPProofHeaderName, ACCESS_TOKEN_REQUEST_ERROR_URI);
+			}
+			else {
+				additionalParameters.put("dpop_proof", dPoPProof);
+				additionalParameters.put("dpop_method", request.getMethod());
+				additionalParameters.put("dpop_target_uri", request.getRequestURL().toString());
+			}
+		}
+	}
+
 	static void throwError(String errorCode, String parameterName, String errorUri) {
 		OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, errorUri);
 		throw new OAuth2AuthenticationException(error);

+ 4 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2RefreshTokenAuthenticationConverter.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.
@@ -88,6 +88,9 @@ public final class OAuth2RefreshTokenAuthenticationConverter implements Authenti
 			}
 		});
 
+		// Validate DPoP Proof HTTP Header (if available)
+		OAuth2EndpointUtils.validateAndAddDPoPParametersIfAvailable(request, additionalParameters);
+
 		return new OAuth2RefreshTokenAuthenticationToken(refreshToken, clientPrincipal, requestedScopes,
 				additionalParameters);
 	}

+ 4 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.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.
@@ -176,6 +176,9 @@ public final class OAuth2TokenExchangeAuthenticationConverter implements Authent
 			}
 		});
 
+		// Validate DPoP Proof HTTP Header (if available)
+		OAuth2EndpointUtils.validateAndAddDPoPParametersIfAvailable(request, additionalParameters);
+
 		return new OAuth2TokenExchangeAuthenticationToken(requestedTokenType, subjectToken, subjectTokenType,
 				clientPrincipal, actorToken, actorTokenType, new LinkedHashSet<>(resources),
 				new LinkedHashSet<>(audiences), requestedScopes, additionalParameters);

+ 39 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.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.
@@ -30,7 +30,11 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -51,12 +55,15 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
 import org.springframework.security.oauth2.core.oidc.OidcScopes;
 import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
 import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
 import org.springframework.security.oauth2.jwt.JoseHeaderNames;
+import org.springframework.security.oauth2.jwt.JwsHeader;
 import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.jwt.JwtClaimsSet;
 import org.springframework.security.oauth2.jwt.JwtEncoder;
 import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
@@ -114,6 +121,8 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 
 	private OAuth2TokenGenerator<?> tokenGenerator;
 
+	private JwtEncoder dPoPProofJwtEncoder;
+
 	private SessionRegistry sessionRegistry;
 
 	private OAuth2AuthorizationCodeAuthenticationProvider authenticationProvider;
@@ -137,6 +146,9 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 				return delegatingTokenGenerator.generate(context);
 			}
 		});
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		this.sessionRegistry = mock(SessionRegistry.class);
 		this.authenticationProvider = new OAuth2AuthorizationCodeAuthenticationProvider(this.authorizationService,
 				this.tokenGenerator);
@@ -470,8 +482,12 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
 		OAuth2AuthorizationRequest authorizationRequest = authorization
 			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
 		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
-				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), additionalParameters);
 
 		given(this.jwtEncoder.encode(any())).willReturn(createJwt());
 
@@ -492,6 +508,7 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 			.isEqualTo(authentication);
 		assertThat(jwtEncodingContext.getJwsHeader()).isNotNull();
 		assertThat(jwtEncodingContext.getClaims()).isNotNull();
+		assertThat(jwtEncodingContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
 
 		ArgumentCaptor<JwtEncoderParameters> jwtEncoderParametersCaptor = ArgumentCaptor
 			.forClass(JwtEncoderParameters.class);
@@ -798,4 +815,24 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 		return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
 	}
 
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 }

+ 43 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.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.
@@ -18,9 +18,15 @@ package org.springframework.security.oauth2.server.authorization.authentication;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
 import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 import java.util.function.Consumer;
 
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -34,10 +40,15 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
 import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
 import org.springframework.security.oauth2.jwt.JoseHeaderNames;
+import org.springframework.security.oauth2.jwt.JwsHeader;
 import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
 import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
@@ -84,6 +95,8 @@ public class OAuth2ClientCredentialsAuthenticationProviderTests {
 
 	private OAuth2TokenGenerator<?> tokenGenerator;
 
+	private JwtEncoder dPoPProofJwtEncoder;
+
 	private OAuth2ClientCredentialsAuthenticationProvider authenticationProvider;
 
 	@BeforeEach
@@ -104,6 +117,9 @@ public class OAuth2ClientCredentialsAuthenticationProviderTests {
 				return delegatingTokenGenerator.generate(context);
 			}
 		});
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		this.authenticationProvider = new OAuth2ClientCredentialsAuthenticationProvider(this.authorizationService,
 				this.tokenGenerator);
 		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
@@ -267,8 +283,12 @@ public class OAuth2ClientCredentialsAuthenticationProviderTests {
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
 		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
 				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
 		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
-				clientPrincipal, null, null);
+				clientPrincipal, null, additionalParameters);
 
 		given(this.jwtEncoder.encode(any())).willReturn(createJwt(registeredClient.getScopes()));
 
@@ -287,6 +307,7 @@ public class OAuth2ClientCredentialsAuthenticationProviderTests {
 			.isEqualTo(authentication);
 		assertThat(jwtEncodingContext.getJwsHeader()).isNotNull();
 		assertThat(jwtEncodingContext.getClaims()).isNotNull();
+		assertThat(jwtEncodingContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
 
 		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
 		verify(this.authorizationService).save(authorizationCaptor.capture());
@@ -352,4 +373,24 @@ public class OAuth2ClientCredentialsAuthenticationProviderTests {
 			.build();
 	}
 
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 }

+ 49 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.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.
@@ -18,10 +18,15 @@ package org.springframework.security.oauth2.server.authorization.authentication;
 import java.security.Principal;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
 import java.util.Map;
+import java.util.UUID;
 import java.util.function.Consumer;
 import java.util.function.Function;
 
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -38,6 +43,14 @@ import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
 import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.OAuth2UserCode;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
@@ -81,6 +94,8 @@ public class OAuth2DeviceCodeAuthenticationProviderTests {
 
 	private OAuth2TokenGenerator<OAuth2Token> tokenGenerator;
 
+	private JwtEncoder dPoPProofJwtEncoder;
+
 	private OAuth2DeviceCodeAuthenticationProvider authenticationProvider;
 
 	@BeforeEach
@@ -88,6 +103,9 @@ public class OAuth2DeviceCodeAuthenticationProviderTests {
 	public void setUp() {
 		this.authorizationService = mock(OAuth2AuthorizationService.class);
 		this.tokenGenerator = mock(OAuth2TokenGenerator.class);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		this.authenticationProvider = new OAuth2DeviceCodeAuthenticationProvider(this.authorizationService,
 				this.tokenGenerator);
 		mockAuthorizationServerContext();
@@ -352,7 +370,15 @@ public class OAuth2DeviceCodeAuthenticationProviderTests {
 	@Test
 	public void authenticateWhenValidDeviceCodeThenReturnAccessTokenAndRefreshToken() {
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
-		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		OAuth2DeviceCodeAuthenticationToken authentication = new OAuth2DeviceCodeAuthenticationToken(DEVICE_CODE,
+				clientPrincipal, additionalParameters);
+
 		// @formatter:off
 		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
 				.token(createDeviceCode())
@@ -396,6 +422,7 @@ public class OAuth2DeviceCodeAuthenticationProviderTests {
 			assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
 			assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
 			assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+			assertThat(tokenContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
 		}
 		assertThat(tokenContextCaptor.getAllValues().get(0).getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
 		assertThat(tokenContextCaptor.getAllValues().get(1).getTokenType()).isEqualTo(OAuth2TokenType.REFRESH_TOKEN);
@@ -448,4 +475,24 @@ public class OAuth2DeviceCodeAuthenticationProviderTests {
 		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
 	}
 
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 }

+ 42 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.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.
@@ -24,7 +24,11 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -43,10 +47,15 @@ import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
 import org.springframework.security.oauth2.core.oidc.OidcScopes;
 import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
 import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
 import org.springframework.security.oauth2.jwt.JoseHeaderNames;
+import org.springframework.security.oauth2.jwt.JwsHeader;
 import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
 import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
@@ -100,6 +109,8 @@ public class OAuth2RefreshTokenAuthenticationProviderTests {
 
 	private OAuth2TokenGenerator<?> tokenGenerator;
 
+	private JwtEncoder dPoPProofJwtEncoder;
+
 	private OAuth2RefreshTokenAuthenticationProvider authenticationProvider;
 
 	@BeforeEach
@@ -122,6 +133,9 @@ public class OAuth2RefreshTokenAuthenticationProviderTests {
 				return delegatingTokenGenerator.generate(context);
 			}
 		});
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		this.authenticationProvider = new OAuth2RefreshTokenAuthenticationProvider(this.authorizationService,
 				this.tokenGenerator);
 		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
@@ -171,8 +185,13 @@ public class OAuth2RefreshTokenAuthenticationProviderTests {
 
 		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
 				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
 		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
-				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null,
+				additionalParameters);
 
 		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
 			.authenticate(authentication);
@@ -191,6 +210,7 @@ public class OAuth2RefreshTokenAuthenticationProviderTests {
 			.isEqualTo(authentication);
 		assertThat(jwtEncodingContext.getJwsHeader()).isNotNull();
 		assertThat(jwtEncodingContext.getClaims()).isNotNull();
+		assertThat(jwtEncodingContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
 
 		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
 		verify(this.authorizationService).save(authorizationCaptor.capture());
@@ -635,4 +655,24 @@ public class OAuth2RefreshTokenAuthenticationProviderTests {
 			.build();
 	}
 
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 }

+ 49 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.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.
@@ -18,11 +18,16 @@ package org.springframework.security.oauth2.server.authorization.authentication;
 import java.security.Principal;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 import java.util.function.Consumer;
 
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -38,6 +43,14 @@ import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
@@ -88,6 +101,8 @@ public class OAuth2TokenExchangeAuthenticationProviderTests {
 
 	private OAuth2TokenGenerator<OAuth2Token> tokenGenerator;
 
+	private JwtEncoder dPoPProofJwtEncoder;
+
 	private OAuth2TokenExchangeAuthenticationProvider authenticationProvider;
 
 	@BeforeEach
@@ -95,6 +110,9 @@ public class OAuth2TokenExchangeAuthenticationProviderTests {
 	public void setUp() {
 		this.authorizationService = mock(OAuth2AuthorizationService.class);
 		this.tokenGenerator = mock(OAuth2TokenGenerator.class);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		this.authenticationProvider = new OAuth2TokenExchangeAuthenticationProvider(this.authorizationService,
 				this.tokenGenerator);
 		mockAuthorizationServerContext();
@@ -506,7 +524,15 @@ public class OAuth2TokenExchangeAuthenticationProviderTests {
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
 			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
 			.build();
-		OAuth2TokenExchangeAuthenticationToken authentication = createImpersonationRequest(registeredClient);
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		OAuth2TokenExchangeAuthenticationToken authentication = new OAuth2TokenExchangeAuthenticationToken(
+				JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE, clientPrincipal, null, null, RESOURCES,
+				AUDIENCES, registeredClient.getScopes(), additionalParameters);
 		TestingAuthenticationToken userPrincipal = new TestingAuthenticationToken("user", null, "ROLE_USER");
 		// @formatter:off
 		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
@@ -544,6 +570,7 @@ public class OAuth2TokenExchangeAuthenticationProviderTests {
 		assertThat(tokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
 		assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
 		assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
+		assertThat(tokenContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
 
 		OAuth2Authorization authorization = authorizationCaptor.getValue();
 		assertThat(authorization.getPrincipalName()).isEqualTo(subjectAuthorization.getPrincipalName());
@@ -738,4 +765,24 @@ public class OAuth2TokenExchangeAuthenticationProviderTests {
 		return (metadata) -> metadata.put(OAuth2TokenFormat.class.getName(), tokenFormat.getValue());
 	}
 
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 }

+ 59 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.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.
@@ -29,6 +29,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 import java.util.function.Consumer;
 
 import com.nimbusds.jose.jwk.JWKSet;
@@ -72,6 +73,7 @@ import org.springframework.security.crypto.keygen.StringKeyGenerator;
 import org.springframework.security.crypto.password.NoOpPasswordEncoder;
 import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
@@ -81,9 +83,13 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
 import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
 import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
 import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
 import org.springframework.security.oauth2.jwt.JwtDecoder;
 import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
 import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
 import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
@@ -189,6 +195,8 @@ public class OAuth2AuthorizationCodeGrantTests {
 
 	private static NimbusJwtEncoder jwtEncoder;
 
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
 	private static AuthorizationServerSettings authorizationServerSettings;
 
 	private static HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
@@ -234,6 +242,9 @@ public class OAuth2AuthorizationCodeGrantTests {
 		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
 		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
 		jwtEncoder = new NimbusJwtEncoder(jwkSource);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		authorizationServerSettings = AuthorizationServerSettings.builder()
 			.authorizationEndpoint("/test/authorize")
 			.tokenEndpoint("/test/token")
@@ -974,6 +985,53 @@ public class OAuth2AuthorizationCodeGrantTests {
 		assertThat(tokenContext.getAuthorizationServerContext().getIssuer()).isEqualTo(issuer);
 	}
 
+	@Test
+	public void requestWhenTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient))
+				.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+
+		authorization = this.authorizationService.findById(authorization.getId());
+		assertThat(authorization.getAccessToken().getClaims()).containsKey("cnf");
+		@SuppressWarnings("unchecked")
+		Map<String, Object> cnfClaims = (Map<String, Object>) authorization.getAccessToken().getClaims().get("cnf");
+		assertThat(cnfClaims).containsKey("jkt");
+	}
+
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 	private static MultiValueMap<String, String> getAuthorizationRequestParameters(RegisteredClient registeredClient) {
 		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
 		parameters.set(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue());

+ 55 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.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.
@@ -22,6 +22,8 @@ import java.time.Duration;
 import java.time.Instant;
 import java.util.Base64;
 import java.util.List;
+import java.util.Map;
+import java.util.UUID;
 import java.util.function.Consumer;
 
 import com.nimbusds.jose.jwk.JWKSet;
@@ -62,6 +64,12 @@ import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.authentication.ClientSecretAuthenticationProvider;
@@ -137,6 +145,8 @@ public class OAuth2ClientCredentialsGrantTests {
 
 	private static OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
 
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
 	private static AuthenticationConverter authenticationConverter;
 
 	private static Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer;
@@ -165,6 +175,9 @@ public class OAuth2ClientCredentialsGrantTests {
 		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
 		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
 		jwtCustomizer = mock(OAuth2TokenCustomizer.class);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		authenticationConverter = mock(AuthenticationConverter.class);
 		authenticationConvertersConsumer = mock(Consumer.class);
 		authenticationProvider = mock(AuthenticationProvider.class);
@@ -461,6 +474,47 @@ public class OAuth2ClientCredentialsGrantTests {
 		assertThat(jwtEncodingContext.getAuthorizationServerContext().getIssuer()).isEqualTo(issuer);
 	}
 
+	@Test
+	public void requestWhenTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1 scope2")
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret()))
+				.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+	}
+
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 	private static String encodeBasicAuth(String clientId, String secret) throws Exception {
 		clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name());
 		secret = URLEncoder.encode(secret, StandardCharsets.UTF_8.name());

+ 90 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.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.
@@ -18,6 +18,7 @@ package org.springframework.security.oauth2.server.authorization.config.annotati
 import java.security.Principal;
 import java.time.Instant;
 import java.util.Map;
+import java.util.UUID;
 import java.util.function.Consumer;
 import java.util.function.Function;
 
@@ -49,6 +50,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
 import org.springframework.security.crypto.password.NoOpPasswordEncoder;
 import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2DeviceCode;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.OAuth2UserCode;
@@ -58,6 +60,12 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
 import org.springframework.security.oauth2.core.http.converter.OAuth2DeviceAuthorizationResponseHttpMessageConverter;
 import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
 import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
@@ -113,6 +121,8 @@ public class OAuth2DeviceCodeGrantTests {
 
 	private static JWKSource<SecurityContext> jwkSource;
 
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
 	private static final HttpMessageConverter<OAuth2DeviceAuthorizationResponse> deviceAuthorizationResponseHttpMessageConverter = new OAuth2DeviceAuthorizationResponseHttpMessageConverter();
 
 	private static final HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
@@ -138,6 +148,9 @@ public class OAuth2DeviceCodeGrantTests {
 	public static void init() {
 		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
 		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		// @formatter:off
 		db = new EmbeddedDatabaseBuilder()
 				.generateUniqueName(true)
@@ -538,6 +551,82 @@ public class OAuth2DeviceCodeGrantTests {
 		assertThat(accessTokenAuthorization).isEqualTo(updatedAuthorization);
 	}
 
+	@Test
+	public void requestWhenAccessTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plusSeconds(300);
+		// @formatter:off
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
+				.principalName(registeredClient.getClientId())
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt))
+				.token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt), withInvalidated())
+				.authorizedScopes(registeredClient.getScopes())
+				.attribute(Principal.class.getName(), new UsernamePasswordAuthenticationToken("user", null))
+				.build();
+		// @formatter:on
+		this.authorizationService.save(authorization);
+
+		// @formatter:off
+		OAuth2AuthorizationConsent authorizationConsent =
+				OAuth2AuthorizationConsent.withId(registeredClient.getClientId(), "user")
+						.scope(registeredClient.getScopes().iterator().next())
+						.build();
+		// @formatter:on
+		this.authorizationConsentService.save(authorizationConsent);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue());
+		parameters.set(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+						.params(parameters)
+						.headers(withClientAuth(registeredClient))
+						.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+		// @formatter:on
+
+		authorization = this.authorizationService.findById(authorization.getId());
+		assertThat(authorization.getAccessToken().getClaims()).containsKey("cnf");
+		@SuppressWarnings("unchecked")
+		Map<String, Object> cnfClaims = (Map<String, Object>) authorization.getAccessToken().getClaims().get("cnf");
+		assertThat(cnfClaims).containsKey("jkt");
+	}
+
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 	private static HttpHeaders withClientAuth(RegisteredClient registeredClient) {
 		HttpHeaders headers = new HttpHeaders();
 		headers.setBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret());

+ 62 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2RefreshTokenGrantTests.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.
@@ -18,10 +18,13 @@ package org.springframework.security.oauth2.server.authorization.config.annotati
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.security.Principal;
+import java.time.Instant;
 import java.util.Base64;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 
 import com.nimbusds.jose.jwk.JWKSet;
 import com.nimbusds.jose.jwk.source.JWKSource;
@@ -70,8 +73,13 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
 import org.springframework.security.oauth2.jose.TestJwks;
 import org.springframework.security.oauth2.jose.TestKeys;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
 import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
 import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
@@ -126,6 +134,8 @@ public class OAuth2RefreshTokenGrantTests {
 
 	private static NimbusJwtDecoder jwtDecoder;
 
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
 	private static HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
 
 	public final SpringTestContext spring = new SpringTestContext();
@@ -147,6 +157,9 @@ public class OAuth2RefreshTokenGrantTests {
 		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
 		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
 		jwtDecoder = NimbusJwtDecoder.withPublicKey(TestKeys.DEFAULT_PUBLIC_KEY).build();
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
 			.setType(EmbeddedDatabaseType.HSQL)
 			.setScriptEncoding("UTF-8")
@@ -266,6 +279,54 @@ public class OAuth2RefreshTokenGrantTests {
 			.andExpect(jsonPath("$.scope").isNotEmpty());
 	}
 
+	@Test
+	public void requestWhenRefreshTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret()))
+				.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+
+		authorization = this.authorizationService.findById(authorization.getId());
+		assertThat(authorization.getAccessToken().getClaims()).containsKey("cnf");
+		@SuppressWarnings("unchecked")
+		Map<String, Object> cnfClaims = (Map<String, Object>) authorization.getAccessToken().getClaims().get("cnf");
+		assertThat(cnfClaims).containsKey("jkt");
+	}
+
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 	private static MultiValueMap<String, String> getRefreshTokenRequestParameters(OAuth2Authorization authorization) {
 		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
 		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.REFRESH_TOKEN.getValue());

+ 89 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.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.
@@ -60,6 +60,12 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenRespon
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
 import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
 import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
@@ -109,6 +115,8 @@ public class OAuth2TokenExchangeGrantTests {
 
 	private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt";
 
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
 	public final SpringTestContext spring = new SpringTestContext();
 
 	private final HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
@@ -129,6 +137,9 @@ public class OAuth2TokenExchangeGrantTests {
 	public static void init() {
 		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
 		AuthorizationServerConfiguration.JWK_SOURCE = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
 		// @formatter:off
 		AuthorizationServerConfiguration.DB = new EmbeddedDatabaseBuilder()
 				.generateUniqueName(true)
@@ -313,6 +324,63 @@ public class OAuth2TokenExchangeGrantTests {
 			.isInstanceOf(OAuth2TokenExchangeCompositeAuthenticationToken.class);
 	}
 
+	@Test
+	public void requestWhenAccessTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		UsernamePasswordAuthenticationToken userPrincipal = createUserPrincipal("user");
+		UsernamePasswordAuthenticationToken adminPrincipal = createUserPrincipal("admin");
+		Map<String, Object> actorTokenClaims = new HashMap<>();
+		actorTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer2");
+		actorTokenClaims.put(OAuth2TokenClaimNames.SUB, "admin");
+		Map<String, Object> subjectTokenClaims = new HashMap<>();
+		subjectTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer1");
+		subjectTokenClaims.put(OAuth2TokenClaimNames.SUB, "user");
+		subjectTokenClaims.put("may_act", actorTokenClaims);
+		OAuth2AccessToken subjectToken = createAccessToken(SUBJECT_TOKEN);
+		OAuth2AccessToken actorToken = createAccessToken(ACTOR_TOKEN);
+		// @formatter:off
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient, subjectToken, subjectTokenClaims)
+				.id(UUID.randomUUID().toString())
+				.attribute(Principal.class.getName(), userPrincipal)
+				.build();
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient, actorToken, actorTokenClaims)
+				.id(UUID.randomUUID().toString())
+				.attribute(Principal.class.getName(), adminPrincipal)
+				.build();
+		// @formatter:on
+		this.authorizationService.save(subjectAuthorization);
+		this.authorizationService.save(actorAuthorization);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue());
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN);
+		parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.ACTOR_TOKEN, ACTOR_TOKEN);
+		parameters.set(OAuth2ParameterNames.ACTOR_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+						.params(parameters)
+						.headers(withClientAuth(registeredClient))
+						.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+		// @formatter:on
+	}
+
 	private static OAuth2AccessToken createAccessToken(String tokenValue) {
 		Instant issuedAt = Instant.now();
 		Instant expiresAt = issuedAt.plusSeconds(300);
@@ -338,6 +406,26 @@ public class OAuth2TokenExchangeGrantTests {
 		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
 	}
 
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
 	@EnableWebSecurity
 	@Import(OAuth2AuthorizationServerConfiguration.class)
 	static class AuthorizationServerConfiguration {

+ 49 - 14
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.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.
@@ -18,6 +18,7 @@ package org.springframework.security.oauth2.server.authorization.web;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 
@@ -67,7 +68,6 @@ import org.springframework.util.StringUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.assertj.core.api.Assertions.entry;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.mock;
@@ -236,6 +236,17 @@ public class OAuth2TokenEndpointFilterTests {
 				OAuth2ErrorCodes.INVALID_REQUEST, request);
 	}
 
+	@Test
+	public void doFilterWhenTokenRequestMultipleDPoPHeaderThenInvalidRequestError() throws Exception {
+		MockHttpServletRequest request = createAuthorizationCodeTokenRequest(
+				TestRegisteredClients.registeredClient().build());
+		request.addHeader(OAuth2AccessToken.TokenType.DPOP.getValue(), "dpop-proof-jwt");
+		request.addHeader(OAuth2AccessToken.TokenType.DPOP.getValue(), "dpop-proof-jwt-2");
+
+		doFilterWhenTokenRequestInvalidParameterThenError(OAuth2AccessToken.TokenType.DPOP.getValue(),
+				OAuth2ErrorCodes.INVALID_REQUEST, request);
+	}
+
 	@Test
 	public void doFilterWhenAuthorizationCodeTokenRequestThenAccessTokenResponse() throws Exception {
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
@@ -256,6 +267,7 @@ public class OAuth2TokenEndpointFilterTests {
 		SecurityContextHolder.setContext(securityContext);
 
 		MockHttpServletRequest request = createAuthorizationCodeTokenRequest(registeredClient);
+		request.addHeader(OAuth2AccessToken.TokenType.DPOP.getValue(), "dpop-proof-jwt");
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		FilterChain filterChain = mock(FilterChain.class);
 
@@ -274,9 +286,14 @@ public class OAuth2TokenEndpointFilterTests {
 		assertThat(authorizationCodeAuthentication.getPrincipal()).isEqualTo(clientPrincipal);
 		assertThat(authorizationCodeAuthentication.getRedirectUri())
 			.isEqualTo(request.getParameter(OAuth2ParameterNames.REDIRECT_URI));
-		assertThat(authorizationCodeAuthentication.getAdditionalParameters()).containsExactly(
-				entry("custom-param-1", "custom-value-1"),
-				entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" }));
+		Map<String, Object> expectedAdditionalParameters = new HashMap<>();
+		expectedAdditionalParameters.put("custom-param-1", "custom-value-1");
+		expectedAdditionalParameters.put("custom-param-2", new String[] { "custom-value-1", "custom-value-2" });
+		expectedAdditionalParameters.put("dpop_proof", "dpop-proof-jwt");
+		expectedAdditionalParameters.put("dpop_method", "POST");
+		expectedAdditionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		assertThat(authorizationCodeAuthentication.getAdditionalParameters())
+			.containsExactlyInAnyOrderEntriesOf(expectedAdditionalParameters);
 		assertThat(authorizationCodeAuthentication.getDetails())
 			.asInstanceOf(InstanceOfAssertFactories.type(WebAuthenticationDetails.class))
 			.extracting(WebAuthenticationDetails::getRemoteAddress)
@@ -324,6 +341,7 @@ public class OAuth2TokenEndpointFilterTests {
 		SecurityContextHolder.setContext(securityContext);
 
 		MockHttpServletRequest request = createClientCredentialsTokenRequest(registeredClient);
+		request.addHeader(OAuth2AccessToken.TokenType.DPOP.getValue(), "dpop-proof-jwt");
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		FilterChain filterChain = mock(FilterChain.class);
 
@@ -339,9 +357,14 @@ public class OAuth2TokenEndpointFilterTests {
 			.getValue();
 		assertThat(clientCredentialsAuthentication.getPrincipal()).isEqualTo(clientPrincipal);
 		assertThat(clientCredentialsAuthentication.getScopes()).isEqualTo(registeredClient.getScopes());
-		assertThat(clientCredentialsAuthentication.getAdditionalParameters()).containsExactly(
-				entry("custom-param-1", "custom-value-1"),
-				entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" }));
+		Map<String, Object> expectedAdditionalParameters = new HashMap<>();
+		expectedAdditionalParameters.put("custom-param-1", "custom-value-1");
+		expectedAdditionalParameters.put("custom-param-2", new String[] { "custom-value-1", "custom-value-2" });
+		expectedAdditionalParameters.put("dpop_proof", "dpop-proof-jwt");
+		expectedAdditionalParameters.put("dpop_method", "POST");
+		expectedAdditionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		assertThat(clientCredentialsAuthentication.getAdditionalParameters())
+			.containsExactlyInAnyOrderEntriesOf(expectedAdditionalParameters);
 		assertThat(clientCredentialsAuthentication.getDetails())
 			.asInstanceOf(InstanceOfAssertFactories.type(WebAuthenticationDetails.class))
 			.extracting(WebAuthenticationDetails::getRemoteAddress)
@@ -412,6 +435,7 @@ public class OAuth2TokenEndpointFilterTests {
 		SecurityContextHolder.setContext(securityContext);
 
 		MockHttpServletRequest request = createRefreshTokenTokenRequest(registeredClient);
+		request.addHeader(OAuth2AccessToken.TokenType.DPOP.getValue(), "dpop-proof-jwt");
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		FilterChain filterChain = mock(FilterChain.class);
 
@@ -428,9 +452,14 @@ public class OAuth2TokenEndpointFilterTests {
 		assertThat(refreshTokenAuthenticationToken.getRefreshToken()).isEqualTo(refreshToken.getTokenValue());
 		assertThat(refreshTokenAuthenticationToken.getPrincipal()).isEqualTo(clientPrincipal);
 		assertThat(refreshTokenAuthenticationToken.getScopes()).isEqualTo(registeredClient.getScopes());
-		assertThat(refreshTokenAuthenticationToken.getAdditionalParameters()).containsExactly(
-				entry("custom-param-1", "custom-value-1"),
-				entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" }));
+		Map<String, Object> expectedAdditionalParameters = new HashMap<>();
+		expectedAdditionalParameters.put("custom-param-1", "custom-value-1");
+		expectedAdditionalParameters.put("custom-param-2", new String[] { "custom-value-1", "custom-value-2" });
+		expectedAdditionalParameters.put("dpop_proof", "dpop-proof-jwt");
+		expectedAdditionalParameters.put("dpop_method", "POST");
+		expectedAdditionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		assertThat(refreshTokenAuthenticationToken.getAdditionalParameters())
+			.containsExactlyInAnyOrderEntriesOf(expectedAdditionalParameters);
 		assertThat(refreshTokenAuthenticationToken.getDetails())
 			.asInstanceOf(InstanceOfAssertFactories.type(WebAuthenticationDetails.class))
 			.extracting(WebAuthenticationDetails::getRemoteAddress)
@@ -473,6 +502,7 @@ public class OAuth2TokenEndpointFilterTests {
 		SecurityContextHolder.setContext(securityContext);
 
 		MockHttpServletRequest request = createTokenExchangeTokenRequest(registeredClient);
+		request.addHeader(OAuth2AccessToken.TokenType.DPOP.getValue(), "dpop-proof-jwt");
 		MockHttpServletResponse response = new MockHttpServletResponse();
 		FilterChain filterChain = mock(FilterChain.class);
 
@@ -490,9 +520,14 @@ public class OAuth2TokenEndpointFilterTests {
 		assertThat(tokenExchangeAuthenticationToken.getSubjectTokenType()).isEqualTo(ACCESS_TOKEN_TYPE);
 		assertThat(tokenExchangeAuthenticationToken.getPrincipal()).isEqualTo(clientPrincipal);
 		assertThat(tokenExchangeAuthenticationToken.getScopes()).isEqualTo(registeredClient.getScopes());
-		assertThat(tokenExchangeAuthenticationToken.getAdditionalParameters()).containsExactly(
-				entry("custom-param-1", "custom-value-1"),
-				entry("custom-param-2", new String[] { "custom-value-1", "custom-value-2" }));
+		Map<String, Object> expectedAdditionalParameters = new HashMap<>();
+		expectedAdditionalParameters.put("custom-param-1", "custom-value-1");
+		expectedAdditionalParameters.put("custom-param-2", new String[] { "custom-value-1", "custom-value-2" });
+		expectedAdditionalParameters.put("dpop_proof", "dpop-proof-jwt");
+		expectedAdditionalParameters.put("dpop_method", "POST");
+		expectedAdditionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		assertThat(tokenExchangeAuthenticationToken.getAdditionalParameters())
+			.containsExactlyInAnyOrderEntriesOf(expectedAdditionalParameters);
 		assertThat(tokenExchangeAuthenticationToken.getDetails())
 			.asInstanceOf(InstanceOfAssertFactories.type(WebAuthenticationDetails.class))
 			.extracting(WebAuthenticationDetails::getRemoteAddress)

+ 12 - 3
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.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,7 @@
  */
 package org.springframework.security.oauth2.server.authorization.web.authentication;
 
+import java.util.HashMap;
 import java.util.Map;
 
 import org.junit.jupiter.api.AfterEach;
@@ -28,6 +29,7 @@ import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.core.context.SecurityContextImpl;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
@@ -107,6 +109,7 @@ public class OAuth2DeviceCodeAuthenticationConverterTests {
 		request.addParameter(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE);
 		request.addParameter("param-1", "value-1");
 		request.addParameter("param-2", "value-1", "value-2");
+		request.addHeader(OAuth2AccessToken.TokenType.DPOP.getValue(), "dpop-proof-jwt");
 
 		SecurityContextImpl securityContext = new SecurityContextImpl();
 		securityContext.setAuthentication(new TestingAuthenticationToken(CLIENT_ID, null));
@@ -117,8 +120,14 @@ public class OAuth2DeviceCodeAuthenticationConverterTests {
 		assertThat(authentication).isNotNull();
 		assertThat(authentication.getDeviceCode()).isEqualTo(DEVICE_CODE);
 		assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
-		assertThat(authentication.getAdditionalParameters()).containsExactly(Map.entry("param-1", "value-1"),
-				Map.entry("param-2", new String[] { "value-1", "value-2" }));
+		Map<String, Object> expectedAdditionalParameters = new HashMap<>();
+		expectedAdditionalParameters.put("param-1", "value-1");
+		expectedAdditionalParameters.put("param-2", new String[] { "value-1", "value-2" });
+		expectedAdditionalParameters.put("dpop_proof", "dpop-proof-jwt");
+		expectedAdditionalParameters.put("dpop_method", "POST");
+		expectedAdditionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		assertThat(authentication.getAdditionalParameters())
+			.containsExactlyInAnyOrderEntriesOf(expectedAdditionalParameters);
 	}
 
 	private static MockHttpServletRequest createRequest() {