diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/AadJwtBearerGrantRequestEntityConverter.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/AadJwtBearerGrantRequestEntityConverter.java index 378b13cfc165..7870cba0b78b 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/AadJwtBearerGrantRequestEntityConverter.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/AadJwtBearerGrantRequestEntityConverter.java @@ -5,10 +5,16 @@ import org.springframework.security.oauth2.client.endpoint.JwtBearerGrantRequest; import org.springframework.security.oauth2.client.endpoint.JwtBearerGrantRequestEntityConverter; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; /** - * This is a special JWT Bearer flow implementation for Microsoft identify platform. + * This is a special JWT Bearer flow implementation for Microsoft identity platform. * * @since 4.3.0 * @see OAuth 2.0 On-Behalf-Of @@ -18,8 +24,25 @@ public class AadJwtBearerGrantRequestEntityConverter extends JwtBearerGrantReque @Override protected MultiValueMap createParameters(JwtBearerGrantRequest jwtBearerGrantRequest) { - MultiValueMap parameters = super.createParameters(jwtBearerGrantRequest); - parameters.add("requested_token_use", "on_behalf_of"); + ClientRegistration clientRegistration = jwtBearerGrantRequest.getClientRegistration(); + MultiValueMap parameters = new LinkedMultiValueMap<>(); + parameters.set(OAuth2ParameterNames.GRANT_TYPE, jwtBearerGrantRequest.getGrantType().getValue()); + parameters.set(OAuth2ParameterNames.ASSERTION, jwtBearerGrantRequest.getJwt().getTokenValue()); + if (!CollectionUtils.isEmpty(clientRegistration.getScopes())) { + parameters.set(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(clientRegistration.getScopes(), " ")); + } + // For CLIENT_SECRET_BASIC: credentials go in Authorization header, not in request parameters + // For CLIENT_SECRET_POST and other methods: client_id goes in request parameters + if (!ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(clientRegistration.getClientAuthenticationMethod())) { + parameters.set(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId()); + } + // For CLIENT_SECRET_POST: client_secret goes in request parameters + // For CLIENT_SECRET_BASIC and other methods: client_secret is handled separately (e.g., in Authorization header) + if (ClientAuthenticationMethod.CLIENT_SECRET_POST.equals(clientRegistration.getClientAuthenticationMethod())) { + parameters.set(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret()); + } + parameters.set("requested_token_use", "on_behalf_of"); return parameters; } } diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/AadJwtBearerGrantRequestEntityConverterTests.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/AadJwtBearerGrantRequestEntityConverterTests.java index ce771c846038..d11e6ed70ab4 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/AadJwtBearerGrantRequestEntityConverterTests.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/aad/security/AadJwtBearerGrantRequestEntityConverterTests.java @@ -8,13 +8,19 @@ import org.springframework.security.oauth2.client.endpoint.JwtBearerGrantRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.jose.jws.JwsAlgorithms; import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import java.time.Instant; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; class AadJwtBearerGrantRequestEntityConverterTests { @@ -31,8 +37,8 @@ void requestedTokenUseParameter() { Jwt jwt = Jwt.withTokenValue("jwt-token-value") .header("alg", JwsAlgorithms.RS256) .claim("sub", "test") - .issuedAt(Instant.ofEpochMilli(Instant.now().toEpochMilli())) - .expiresAt(Instant.ofEpochMilli(Instant.now().plusSeconds(60).toEpochMilli())) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) .build(); JwtBearerGrantRequest request = new JwtBearerGrantRequest(clientRegistration, jwt); AadJwtBearerGrantRequestEntityConverter converter = @@ -43,4 +49,134 @@ void requestedTokenUseParameter() { assertTrue(parameters.containsKey("requested_token_use")); assertEquals("on_behalf_of", parameters.getFirst("requested_token_use")); } + + @SuppressWarnings("unchecked") + @Test + void grantTypeIsNotDuplicatedWhenParametersConverterIsAdded() { + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("test") + .clientId("test") + .clientSecret("test-secret") + .authorizationGrantType(AuthorizationGrantType.JWT_BEARER) + .tokenUri("http://localhost/token") + .build(); + Jwt jwt = Jwt.withTokenValue("jwt-token-value") + .header("alg", JwsAlgorithms.RS256) + .claim("sub", "test") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .build(); + JwtBearerGrantRequest request = new JwtBearerGrantRequest(clientRegistration, jwt); + + // Create converter and add a parameters converter that returns additional parameters + AadJwtBearerGrantRequestEntityConverter converter = new AadJwtBearerGrantRequestEntityConverter(); + converter.addParametersConverter((grantRequest) -> { + MultiValueMap additionalParams = new LinkedMultiValueMap<>(); + additionalParams.set("custom_param", "custom_value"); + return additionalParams; + }); + + RequestEntity> entity = + (RequestEntity>) converter.convert(request); + MultiValueMap parameters = entity.getBody(); + + // Verify that grant_type exists + assertTrue(parameters.containsKey(OAuth2ParameterNames.GRANT_TYPE)); + + // Verify that grant_type is a single value, not a list + List grantTypeValues = parameters.get(OAuth2ParameterNames.GRANT_TYPE); + assertNotNull(grantTypeValues); + assertEquals(1, grantTypeValues.size(), + "Grant type should be a single value, not duplicated. Found: " + grantTypeValues.size() + " values"); + assertEquals("urn:ietf:params:oauth:grant-type:jwt-bearer", grantTypeValues.get(0)); + + // Verify the custom parameter was added + assertTrue(parameters.containsKey("custom_param")); + assertEquals("custom_value", parameters.getFirst("custom_param")); + + // Verify requested_token_use is present + assertTrue(parameters.containsKey("requested_token_use")); + assertEquals("on_behalf_of", parameters.getFirst("requested_token_use")); + } + + @SuppressWarnings("unchecked") + @Test + void allRequiredParametersArePresent() { + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("test") + .clientId("test") + .clientSecret("test-secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.JWT_BEARER) + .tokenUri("http://localhost/token") + .scope("openid", "profile") + .build(); + Jwt jwt = Jwt.withTokenValue("jwt-token-value") + .header("alg", JwsAlgorithms.RS256) + .claim("sub", "test") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .build(); + JwtBearerGrantRequest request = new JwtBearerGrantRequest(clientRegistration, jwt); + AadJwtBearerGrantRequestEntityConverter converter = + new AadJwtBearerGrantRequestEntityConverter(); + RequestEntity> entity = + (RequestEntity>) converter.convert(request); + MultiValueMap parameters = entity.getBody(); + + // Verify all required parameters + assertTrue(parameters.containsKey(OAuth2ParameterNames.GRANT_TYPE)); + assertEquals("urn:ietf:params:oauth:grant-type:jwt-bearer", + parameters.getFirst(OAuth2ParameterNames.GRANT_TYPE)); + + assertTrue(parameters.containsKey(OAuth2ParameterNames.ASSERTION)); + assertEquals("jwt-token-value", parameters.getFirst(OAuth2ParameterNames.ASSERTION)); + + assertTrue(parameters.containsKey(OAuth2ParameterNames.SCOPE)); + assertEquals("openid profile", parameters.getFirst(OAuth2ParameterNames.SCOPE)); + + // For CLIENT_SECRET_POST, both client_id and client_secret should be in parameters + assertTrue(parameters.containsKey(OAuth2ParameterNames.CLIENT_ID)); + assertEquals("test", parameters.getFirst(OAuth2ParameterNames.CLIENT_ID)); + + assertTrue(parameters.containsKey(OAuth2ParameterNames.CLIENT_SECRET)); + assertEquals("test-secret", parameters.getFirst(OAuth2ParameterNames.CLIENT_SECRET)); + + assertTrue(parameters.containsKey("requested_token_use")); + assertEquals("on_behalf_of", parameters.getFirst("requested_token_use")); + } + + @SuppressWarnings("unchecked") + @Test + void clientSecretBasicAuthenticationMethod() { + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("test") + .clientId("test") + .clientSecret("test-secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.JWT_BEARER) + .tokenUri("http://localhost/token") + .scope("openid", "profile") + .build(); + Jwt jwt = Jwt.withTokenValue("jwt-token-value") + .header("alg", JwsAlgorithms.RS256) + .claim("sub", "test") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .build(); + JwtBearerGrantRequest request = new JwtBearerGrantRequest(clientRegistration, jwt); + AadJwtBearerGrantRequestEntityConverter converter = + new AadJwtBearerGrantRequestEntityConverter(); + RequestEntity> entity = + (RequestEntity>) converter.convert(request); + MultiValueMap parameters = entity.getBody(); + + // For CLIENT_SECRET_BASIC, credentials should NOT be in the parameters (they go in the Authorization header) + assertFalse(parameters.containsKey(OAuth2ParameterNames.CLIENT_ID), + "CLIENT_ID should not be in parameters for CLIENT_SECRET_BASIC"); + assertFalse(parameters.containsKey(OAuth2ParameterNames.CLIENT_SECRET), + "CLIENT_SECRET should not be in parameters for CLIENT_SECRET_BASIC"); + + // But other parameters should still be present + assertTrue(parameters.containsKey(OAuth2ParameterNames.GRANT_TYPE)); + assertTrue(parameters.containsKey(OAuth2ParameterNames.ASSERTION)); + assertTrue(parameters.containsKey("requested_token_use")); + } }