diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java index daf9bf5d..f1b50039 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java +++ b/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 scopes = Collections.emptySet(); String accessTokenScopes = rs.getString("access_token_scopes"); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/DPoPProofVerifier.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/DPoPProofVerifier.java new file mode 100644 index 00000000..161eaf23 --- /dev/null +++ b/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 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; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthenticationProviderUtils.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthenticationProviderUtils.java index cb16c281..2448f4b8 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthenticationProviderUtils.java +++ b/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 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 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(); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java index c0039361..51e3bd81 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java +++ b/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); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProvider.java index da18baba..1e145076 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProvider.java +++ b/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 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) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java index 115cbd5b..cc04ae3e 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java +++ b/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) diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java index c4b89d66..cfa9e918 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProvider.java +++ b/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); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java index 69b0613d..e97c7761 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java +++ b/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(); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DefaultOAuth2TokenCustomizers.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DefaultOAuth2TokenCustomizers.java index 3b054c35..bbd04464 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DefaultOAuth2TokenCustomizers.java +++ b/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 claims) { + Map 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 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 jwkJson = (Map) 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); + } + } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java index 40804ea5..249c86e3 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java +++ b/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 { 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(); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/OAuth2AccessTokenGenerator.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/OAuth2AccessTokenGenerator.java index 159660af..4f03c166 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/OAuth2AccessTokenGenerator.java +++ b/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 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); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2RefreshTokenAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2RefreshTokenAuthenticationConverter.java index 805f0d71..74077649 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2RefreshTokenAuthenticationConverter.java +++ b/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); } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java index dad2e1f5..01a121af 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java +++ b/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); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java index eef19ed0..ed13047e 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java +++ b/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 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 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.get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull(); ArgumentCaptor 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 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(); + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java index b0c2a575..6ad95f50 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java +++ b/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 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 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.get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull(); ArgumentCaptor 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 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(); + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java index 0c37bb16..8c71bf7e 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java +++ b/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 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 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 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.getAuthorizationGrant()).isEqualTo(authentication); + assertThat(tokenContext.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 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(); + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java index beb2b00a..32eb57ed 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java +++ b/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 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 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.get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull(); ArgumentCaptor 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 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(); + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java index 6e1f6ce5..8e8001d4 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java +++ b/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 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 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 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.getAuthorizationGrant()).isEqualTo(authentication); assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + assertThat(tokenContext.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 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(); + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java index 771839e7..e2099eac 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java +++ b/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 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 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 cnfClaims = (Map) authorization.getAccessToken().getClaims().get("cnf"); + assertThat(cnfClaims).containsKey("jkt"); + } + + private static String generateDPoPProof(String tokenEndpointUri) { + // @formatter:off + Map 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 getAuthorizationRequestParameters(RegisteredClient registeredClient) { MultiValueMap parameters = new LinkedMultiValueMap<>(); parameters.set(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue()); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java index 95cee159..13c4373a 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java +++ b/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 jwtCustomizer; + private static NimbusJwtEncoder dPoPProofJwtEncoder; + private static AuthenticationConverter authenticationConverter; private static Consumer> 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 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 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()); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java index 0f8735a4..19df0f31 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java +++ b/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 jwkSource; + private static NimbusJwtEncoder dPoPProofJwtEncoder; + private static final HttpMessageConverter deviceAuthorizationResponseHttpMessageConverter = new OAuth2DeviceAuthorizationResponseHttpMessageConverter(); private static final HttpMessageConverter 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 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 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 cnfClaims = (Map) authorization.getAccessToken().getClaims().get("cnf"); + assertThat(cnfClaims).containsKey("jkt"); + } + + private static String generateDPoPProof(String tokenEndpointUri) { + // @formatter:off + Map 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()); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2RefreshTokenGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2RefreshTokenGrantTests.java index 7d8bca81..d916cce0 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2RefreshTokenGrantTests.java +++ b/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 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 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 cnfClaims = (Map) authorization.getAccessToken().getClaims().get("cnf"); + assertThat(cnfClaims).containsKey("jkt"); + } + + private static String generateDPoPProof(String tokenEndpointUri) { + // @formatter:off + Map 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 getRefreshTokenRequestParameters(OAuth2Authorization authorization) { MultiValueMap parameters = new LinkedMultiValueMap<>(); parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.REFRESH_TOKEN.getValue()); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java index b9b959cf..e3418802 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java +++ b/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 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 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 actorTokenClaims = new HashMap<>(); + actorTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer2"); + actorTokenClaims.put(OAuth2TokenClaimNames.SUB, "admin"); + Map 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 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 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 { diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java index 98a8cbbf..76e3b773 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java +++ b/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 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 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 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 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) diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java index bb84e9f2..3ec7f458 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java +++ b/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 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() {