Переглянути джерело

Add Jwt Client Authentication support

Closes gh-8175
Joe Grandja 4 роки тому
батько
коміт
9c97970e26
36 змінених файлів з 3388 додано та 433 видалено
  1. 1 0
      etc/checkstyle/checkstyle-suppressions.xml
  2. 33 1
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractOAuth2AuthorizationGrantRequest.java
  3. 173 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractOAuth2AuthorizationGrantRequestEntityConverter.java
  4. 390 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeader.java
  5. 127 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeaderNames.java
  6. 222 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSet.java
  7. 62 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtEncodingException.java
  8. 359 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoder.java
  9. 183 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverter.java
  10. 2 14
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequest.java
  11. 16 42
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverter.java
  12. 2 14
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequest.java
  13. 14 40
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityConverter.java
  14. 2 14
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequest.java
  15. 14 40
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequestEntityConverter.java
  16. 2 14
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2RefreshTokenGrantRequest.java
  17. 14 41
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2RefreshTokenGrantRequestEntityConverter.java
  18. 107 51
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClientTests.java
  19. 104 34
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultClientCredentialsTokenResponseClientTests.java
  20. 100 13
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultPasswordTokenResponseClientTests.java
  21. 103 14
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultRefreshTokenTokenResponseClientTests.java
  22. 123 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JoseHeaderTests.java
  23. 105 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSetTests.java
  24. 347 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoderTests.java
  25. 175 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverterTests.java
  26. 93 54
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java
  27. 71 19
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityConverterTests.java
  28. 70 14
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequestEntityConverterTests.java
  29. 76 10
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2RefreshTokenGrantRequestEntityConverterTests.java
  30. 76 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJoseHeaders.java
  31. 64 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJwtClaimsSets.java
  32. 12 1
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClientAuthenticationMethod.java
  33. 13 1
      oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java
  34. 11 1
      oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/ClientAuthenticationMethodTests.java
  35. 86 0
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/TestJwks.java
  36. 36 1
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/TestKeys.java

+ 1 - 0
etc/checkstyle/checkstyle-suppressions.xml

@@ -49,4 +49,5 @@
 	<suppress files="WebSocketMessageBrokerConfigTests\.java" checks="SpringMethodVisibility"/>
 	<suppress files="WebSecurityConfigurationTests\.java" checks="SpringMethodVisibility"/>
 	<suppress files="WithSecurityContextTestExecutionListenerTests\.java" checks="SpringMethodVisibility"/>
+	<suppress files="AbstractOAuth2AuthorizationGrantRequestEntityConverter\.java" checks="SpringMethodVisibility"/>
 </suppressions>

+ 33 - 1
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractOAuth2AuthorizationGrantRequest.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2017 the original author or authors.
+ * Copyright 2002-2021 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.client.endpoint;
 
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.util.Assert;
 
@@ -27,6 +28,7 @@ import org.springframework.util.Assert;
  * @author Joe Grandja
  * @since 5.0
  * @see AuthorizationGrantType
+ * @see ClientRegistration
  * @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-1.3">Section
  * 1.3 Authorization Grant</a>
  */
@@ -34,13 +36,34 @@ public abstract class AbstractOAuth2AuthorizationGrantRequest {
 
 	private final AuthorizationGrantType authorizationGrantType;
 
+	private final ClientRegistration clientRegistration;
+
 	/**
 	 * Sub-class constructor.
 	 * @param authorizationGrantType the authorization grant type
+	 * @deprecated Use
+	 * {@link #AbstractOAuth2AuthorizationGrantRequest(AuthorizationGrantType, ClientRegistration)}
+	 * instead
 	 */
+	@Deprecated
 	protected AbstractOAuth2AuthorizationGrantRequest(AuthorizationGrantType authorizationGrantType) {
 		Assert.notNull(authorizationGrantType, "authorizationGrantType cannot be null");
 		this.authorizationGrantType = authorizationGrantType;
+		this.clientRegistration = null;
+	}
+
+	/**
+	 * Sub-class constructor.
+	 * @param authorizationGrantType the authorization grant type
+	 * @param clientRegistration the client registration
+	 * @since 5.5
+	 */
+	protected AbstractOAuth2AuthorizationGrantRequest(AuthorizationGrantType authorizationGrantType,
+			ClientRegistration clientRegistration) {
+		Assert.notNull(authorizationGrantType, "authorizationGrantType cannot be null");
+		Assert.notNull(clientRegistration, "clientRegistration cannot be null");
+		this.authorizationGrantType = authorizationGrantType;
+		this.clientRegistration = clientRegistration;
 	}
 
 	/**
@@ -51,4 +74,13 @@ public abstract class AbstractOAuth2AuthorizationGrantRequest {
 		return this.authorizationGrantType;
 	}
 
+	/**
+	 * Returns the {@link ClientRegistration client registration}.
+	 * @return the {@link ClientRegistration}
+	 * @since 5.5
+	 */
+	public ClientRegistration getClientRegistration() {
+		return this.clientRegistration;
+	}
+
 }

+ 173 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/AbstractOAuth2AuthorizationGrantRequestEntityConverter.java

@@ -0,0 +1,173 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import java.net.URI;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.RequestEntity;
+import org.springframework.util.Assert;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * Base implementation of a {@link Converter} that converts the provided
+ * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link RequestEntity}
+ * representation of an OAuth 2.0 Access Token Request for the Authorization Grant.
+ *
+ * @param <T> the type of {@link AbstractOAuth2AuthorizationGrantRequest}
+ * @author Joe Grandja
+ * @since 5.5
+ * @see Converter
+ * @see AbstractOAuth2AuthorizationGrantRequest
+ * @see RequestEntity
+ */
+abstract class AbstractOAuth2AuthorizationGrantRequestEntityConverter<T extends AbstractOAuth2AuthorizationGrantRequest>
+		implements Converter<T, RequestEntity<?>> {
+
+	// @formatter:off
+	private Converter<T, HttpHeaders> headersConverter =
+			(authorizationGrantRequest) -> OAuth2AuthorizationGrantRequestEntityUtils
+					.getTokenRequestHeaders(authorizationGrantRequest.getClientRegistration());
+	// @formatter:on
+
+	private Converter<T, MultiValueMap<String, String>> parametersConverter = this::createParameters;
+
+	@Override
+	public RequestEntity<?> convert(T authorizationGrantRequest) {
+		HttpHeaders headers = getHeadersConverter().convert(authorizationGrantRequest);
+		MultiValueMap<String, String> parameters = getParametersConverter().convert(authorizationGrantRequest);
+		URI uri = UriComponentsBuilder
+				.fromUriString(authorizationGrantRequest.getClientRegistration().getProviderDetails().getTokenUri())
+				.build().toUri();
+		return new RequestEntity<>(parameters, headers, HttpMethod.POST, uri);
+	}
+
+	/**
+	 * Returns a {@link MultiValueMap} of the parameters used in the OAuth 2.0 Access
+	 * Token Request body.
+	 * @param authorizationGrantRequest the authorization grant request
+	 * @return a {@link MultiValueMap} of the parameters used in the OAuth 2.0 Access
+	 * Token Request body
+	 */
+	abstract MultiValueMap<String, String> createParameters(T authorizationGrantRequest);
+
+	/**
+	 * Returns the {@link Converter} used for converting the
+	 * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link HttpHeaders}
+	 * used in the OAuth 2.0 Access Token Request headers.
+	 * @return the {@link Converter} used for converting the
+	 * {@link OAuth2AuthorizationCodeGrantRequest} to {@link HttpHeaders}
+	 */
+	final Converter<T, HttpHeaders> getHeadersConverter() {
+		return this.headersConverter;
+	}
+
+	/**
+	 * Sets the {@link Converter} used for converting the
+	 * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link HttpHeaders}
+	 * used in the OAuth 2.0 Access Token Request headers.
+	 * @param headersConverter the {@link Converter} used for converting the
+	 * {@link OAuth2AuthorizationCodeGrantRequest} to {@link HttpHeaders}
+	 */
+	public final void setHeadersConverter(Converter<T, HttpHeaders> headersConverter) {
+		Assert.notNull(headersConverter, "headersConverter cannot be null");
+		this.headersConverter = headersConverter;
+	}
+
+	/**
+	 * Add (compose) the provided {@code headersConverter} to the current
+	 * {@link Converter} used for converting the
+	 * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link HttpHeaders}
+	 * used in the OAuth 2.0 Access Token Request headers.
+	 * @param headersConverter the {@link Converter} to add (compose) to the current
+	 * {@link Converter} used for converting the
+	 * {@link OAuth2AuthorizationCodeGrantRequest} to a {@link HttpHeaders}
+	 */
+	public final void addHeadersConverter(Converter<T, HttpHeaders> headersConverter) {
+		Assert.notNull(headersConverter, "headersConverter cannot be null");
+		Converter<T, HttpHeaders> currentHeadersConverter = this.headersConverter;
+		this.headersConverter = (authorizationGrantRequest) -> {
+			// Append headers using a Composite Converter
+			HttpHeaders headers = currentHeadersConverter.convert(authorizationGrantRequest);
+			if (headers == null) {
+				headers = new HttpHeaders();
+			}
+			HttpHeaders headersToAdd = headersConverter.convert(authorizationGrantRequest);
+			if (headersToAdd != null) {
+				headers.addAll(headersToAdd);
+			}
+			return headers;
+		};
+	}
+
+	/**
+	 * Returns the {@link Converter} used for converting the
+	 * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link MultiValueMap}
+	 * of the parameters used in the OAuth 2.0 Access Token Request body.
+	 * @return the {@link Converter} used for converting the
+	 * {@link OAuth2AuthorizationCodeGrantRequest} to a {@link MultiValueMap} of the
+	 * parameters
+	 */
+	final Converter<T, MultiValueMap<String, String>> getParametersConverter() {
+		return this.parametersConverter;
+	}
+
+	/**
+	 * Sets the {@link Converter} used for converting the
+	 * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link MultiValueMap}
+	 * of the parameters used in the OAuth 2.0 Access Token Request body.
+	 * @param parametersConverter the {@link Converter} used for converting the
+	 * {@link OAuth2AuthorizationCodeGrantRequest} to a {@link MultiValueMap} of the
+	 * parameters
+	 */
+	public final void setParametersConverter(Converter<T, MultiValueMap<String, String>> parametersConverter) {
+		Assert.notNull(parametersConverter, "parametersConverter cannot be null");
+		this.parametersConverter = parametersConverter;
+	}
+
+	/**
+	 * Add (compose) the provided {@code parametersConverter} to the current
+	 * {@link Converter} used for converting the
+	 * {@link AbstractOAuth2AuthorizationGrantRequest} instance to a {@link MultiValueMap}
+	 * of the parameters used in the OAuth 2.0 Access Token Request body.
+	 * @param parametersConverter the {@link Converter} to add (compose) to the current
+	 * {@link Converter} used for converting the
+	 * {@link OAuth2AuthorizationCodeGrantRequest} to a {@link MultiValueMap} of the
+	 * parameters
+	 */
+	public final void addParametersConverter(Converter<T, MultiValueMap<String, String>> parametersConverter) {
+		Assert.notNull(parametersConverter, "parametersConverter cannot be null");
+		Converter<T, MultiValueMap<String, String>> currentParametersConverter = this.parametersConverter;
+		this.parametersConverter = (authorizationGrantRequest) -> {
+			// Append parameters using a Composite Converter
+			MultiValueMap<String, String> parameters = currentParametersConverter.convert(authorizationGrantRequest);
+			if (parameters == null) {
+				parameters = new LinkedMultiValueMap<>();
+			}
+			MultiValueMap<String, String> parametersToAdd = parametersConverter.convert(authorizationGrantRequest);
+			if (parametersToAdd != null) {
+				parameters.addAll(parametersToAdd);
+			}
+			return parameters;
+		};
+	}
+
+}

+ 390 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeader.java

@@ -0,0 +1,390 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import java.net.URL;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
+import org.springframework.security.oauth2.jose.JwaAlgorithm;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.util.Assert;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * The JOSE header is a JSON object representing the header parameters of a JSON Web
+ * Token, whether the JWT is a JWS or JWE, that describe the cryptographic operations
+ * applied to the JWT and optionally, additional properties of the JWT.
+ *
+ * @author Anoop Garlapati
+ * @author Joe Grandja
+ * @since 5.5
+ * @see Jwt
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7519#section-5">JWT JOSE
+ * Header</a>
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7515#section-4">JWS JOSE
+ * Header</a>
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7516#section-4">JWE JOSE
+ * Header</a>
+ */
+final class JoseHeader {
+
+	private final Map<String, Object> headers;
+
+	private JoseHeader(Map<String, Object> headers) {
+		this.headers = Collections.unmodifiableMap(new HashMap<>(headers));
+	}
+
+	/**
+	 * Returns the {@link JwaAlgorithm JWA algorithm} used to digitally sign the JWS or
+	 * encrypt the JWE.
+	 * @return the {@link JwaAlgorithm}
+	 */
+	@SuppressWarnings("unchecked")
+	<T extends JwaAlgorithm> T getAlgorithm() {
+		return (T) getHeader(JoseHeaderNames.ALG);
+	}
+
+	/**
+	 * Returns the JWK Set URL that refers to the resource of a set of JSON-encoded public
+	 * keys, one of which corresponds to the key used to digitally sign the JWS or encrypt
+	 * the JWE.
+	 * @return the JWK Set URL
+	 */
+	URL getJwkSetUrl() {
+		return getHeader(JoseHeaderNames.JKU);
+	}
+
+	/**
+	 * Returns the JSON Web Key which is the public key that corresponds to the key used
+	 * to digitally sign the JWS or encrypt the JWE.
+	 * @return the JSON Web Key
+	 */
+	Map<String, Object> getJwk() {
+		return getHeader(JoseHeaderNames.JWK);
+	}
+
+	/**
+	 * Returns the key ID that is a hint indicating which key was used to secure the JWS
+	 * or JWE.
+	 * @return the key ID
+	 */
+	String getKeyId() {
+		return getHeader(JoseHeaderNames.KID);
+	}
+
+	/**
+	 * Returns the X.509 URL that refers to the resource for the X.509 public key
+	 * certificate or certificate chain corresponding to the key used to digitally sign
+	 * the JWS or encrypt the JWE.
+	 * @return the X.509 URL
+	 */
+	URL getX509Url() {
+		return getHeader(JoseHeaderNames.X5U);
+	}
+
+	/**
+	 * Returns the X.509 certificate chain that contains the X.509 public key certificate
+	 * or certificate chain corresponding to the key used to digitally sign the JWS or
+	 * encrypt the JWE. The certificate or certificate chain is represented as a
+	 * {@code List} of certificate value {@code String}s. Each {@code String} in the
+	 * {@code List} is a Base64-encoded DER PKIX certificate value.
+	 * @return the X.509 certificate chain
+	 */
+	List<String> getX509CertificateChain() {
+		return getHeader(JoseHeaderNames.X5C);
+	}
+
+	/**
+	 * Returns the X.509 certificate SHA-1 thumbprint that is a base64url-encoded SHA-1
+	 * thumbprint (a.k.a. digest) of the DER encoding of the X.509 certificate
+	 * corresponding to the key used to digitally sign the JWS or encrypt the JWE.
+	 * @return the X.509 certificate SHA-1 thumbprint
+	 */
+	String getX509SHA1Thumbprint() {
+		return getHeader(JoseHeaderNames.X5T);
+	}
+
+	/**
+	 * Returns the X.509 certificate SHA-256 thumbprint that is a base64url-encoded
+	 * SHA-256 thumbprint (a.k.a. digest) of the DER encoding of the X.509 certificate
+	 * corresponding to the key used to digitally sign the JWS or encrypt the JWE.
+	 * @return the X.509 certificate SHA-256 thumbprint
+	 */
+	String getX509SHA256Thumbprint() {
+		return getHeader(JoseHeaderNames.X5T_S256);
+	}
+
+	/**
+	 * Returns the type header that declares the media type of the JWS/JWE.
+	 * @return the type header
+	 */
+	String getType() {
+		return getHeader(JoseHeaderNames.TYP);
+	}
+
+	/**
+	 * Returns the content type header that declares the media type of the secured content
+	 * (the payload).
+	 * @return the content type header
+	 */
+	String getContentType() {
+		return getHeader(JoseHeaderNames.CTY);
+	}
+
+	/**
+	 * Returns the critical headers that indicates which extensions to the JWS/JWE/JWA
+	 * specifications are being used that MUST be understood and processed.
+	 * @return the critical headers
+	 */
+	Set<String> getCritical() {
+		return getHeader(JoseHeaderNames.CRIT);
+	}
+
+	/**
+	 * Returns the headers.
+	 * @return the headers
+	 */
+	Map<String, Object> getHeaders() {
+		return this.headers;
+	}
+
+	/**
+	 * Returns the header value.
+	 * @param name the header name
+	 * @param <T> the type of the header value
+	 * @return the header value
+	 */
+	@SuppressWarnings("unchecked")
+	<T> T getHeader(String name) {
+		Assert.hasText(name, "name cannot be empty");
+		return (T) getHeaders().get(name);
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the provided {@link JwaAlgorithm}.
+	 * @param jwaAlgorithm the {@link JwaAlgorithm}
+	 * @return the {@link Builder}
+	 */
+	static Builder withAlgorithm(JwaAlgorithm jwaAlgorithm) {
+		return new Builder(jwaAlgorithm);
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the provided {@code headers}.
+	 * @param headers the headers
+	 * @return the {@link Builder}
+	 */
+	static Builder from(JoseHeader headers) {
+		return new Builder(headers);
+	}
+
+	/**
+	 * A builder for {@link JoseHeader}.
+	 */
+	static final class Builder {
+
+		final Map<String, Object> headers = new HashMap<>();
+
+		private Builder(JwaAlgorithm jwaAlgorithm) {
+			algorithm(jwaAlgorithm);
+		}
+
+		private Builder(JoseHeader headers) {
+			Assert.notNull(headers, "headers cannot be null");
+			this.headers.putAll(headers.getHeaders());
+		}
+
+		/**
+		 * Sets the {@link JwaAlgorithm JWA algorithm} used to digitally sign the JWS or
+		 * encrypt the JWE.
+		 * @param jwaAlgorithm the {@link JwaAlgorithm}
+		 * @return the {@link Builder}
+		 */
+		Builder algorithm(JwaAlgorithm jwaAlgorithm) {
+			Assert.notNull(jwaAlgorithm, "jwaAlgorithm cannot be null");
+			return header(JoseHeaderNames.ALG, jwaAlgorithm);
+		}
+
+		/**
+		 * Sets the JWK Set URL that refers to the resource of a set of JSON-encoded
+		 * public keys, one of which corresponds to the key used to digitally sign the JWS
+		 * or encrypt the JWE.
+		 * @param jwkSetUrl the JWK Set URL
+		 * @return the {@link Builder}
+		 */
+		Builder jwkSetUrl(String jwkSetUrl) {
+			return header(JoseHeaderNames.JKU, convertAsURL(JoseHeaderNames.JKU, jwkSetUrl));
+		}
+
+		/**
+		 * Sets the JSON Web Key which is the public key that corresponds to the key used
+		 * to digitally sign the JWS or encrypt the JWE.
+		 * @param jwk the JSON Web Key
+		 * @return the {@link Builder}
+		 */
+		Builder jwk(Map<String, Object> jwk) {
+			return header(JoseHeaderNames.JWK, jwk);
+		}
+
+		/**
+		 * Sets the key ID that is a hint indicating which key was used to secure the JWS
+		 * or JWE.
+		 * @param keyId the key ID
+		 * @return the {@link Builder}
+		 */
+		Builder keyId(String keyId) {
+			return header(JoseHeaderNames.KID, keyId);
+		}
+
+		/**
+		 * Sets the X.509 URL that refers to the resource for the X.509 public key
+		 * certificate or certificate chain corresponding to the key used to digitally
+		 * sign the JWS or encrypt the JWE.
+		 * @param x509Url the X.509 URL
+		 * @return the {@link Builder}
+		 */
+		Builder x509Url(String x509Url) {
+			return header(JoseHeaderNames.X5U, convertAsURL(JoseHeaderNames.X5U, x509Url));
+		}
+
+		/**
+		 * Sets the X.509 certificate chain that contains the X.509 public key certificate
+		 * or certificate chain corresponding to the key used to digitally sign the JWS or
+		 * encrypt the JWE. The certificate or certificate chain is represented as a
+		 * {@code List} of certificate value {@code String}s. Each {@code String} in the
+		 * {@code List} is a Base64-encoded DER PKIX certificate value.
+		 * @param x509CertificateChain the X.509 certificate chain
+		 * @return the {@link Builder}
+		 */
+		Builder x509CertificateChain(List<String> x509CertificateChain) {
+			return header(JoseHeaderNames.X5C, x509CertificateChain);
+		}
+
+		/**
+		 * Sets the X.509 certificate SHA-1 thumbprint that is a base64url-encoded SHA-1
+		 * thumbprint (a.k.a. digest) of the DER encoding of the X.509 certificate
+		 * corresponding to the key used to digitally sign the JWS or encrypt the JWE.
+		 * @param x509SHA1Thumbprint the X.509 certificate SHA-1 thumbprint
+		 * @return the {@link Builder}
+		 */
+		Builder x509SHA1Thumbprint(String x509SHA1Thumbprint) {
+			return header(JoseHeaderNames.X5T, x509SHA1Thumbprint);
+		}
+
+		/**
+		 * Sets the X.509 certificate SHA-256 thumbprint that is a base64url-encoded
+		 * SHA-256 thumbprint (a.k.a. digest) of the DER encoding of the X.509 certificate
+		 * corresponding to the key used to digitally sign the JWS or encrypt the JWE.
+		 * @param x509SHA256Thumbprint the X.509 certificate SHA-256 thumbprint
+		 * @return the {@link Builder}
+		 */
+		Builder x509SHA256Thumbprint(String x509SHA256Thumbprint) {
+			return header(JoseHeaderNames.X5T_S256, x509SHA256Thumbprint);
+		}
+
+		/**
+		 * Sets the type header that declares the media type of the JWS/JWE.
+		 * @param type the type header
+		 * @return the {@link Builder}
+		 */
+		Builder type(String type) {
+			return header(JoseHeaderNames.TYP, type);
+		}
+
+		/**
+		 * Sets the content type header that declares the media type of the secured
+		 * content (the payload).
+		 * @param contentType the content type header
+		 * @return the {@link Builder}
+		 */
+		Builder contentType(String contentType) {
+			return header(JoseHeaderNames.CTY, contentType);
+		}
+
+		/**
+		 * Sets the critical headers that indicates which extensions to the JWS/JWE/JWA
+		 * specifications are being used that MUST be understood and processed.
+		 * @param headerNames the critical header names
+		 * @return the {@link Builder}
+		 */
+		Builder critical(Set<String> headerNames) {
+			return header(JoseHeaderNames.CRIT, headerNames);
+		}
+
+		/**
+		 * Sets the header.
+		 * @param name the header name
+		 * @param value the header value
+		 * @return the {@link Builder}
+		 */
+		Builder header(String name, Object value) {
+			Assert.hasText(name, "name cannot be empty");
+			Assert.notNull(value, "value cannot be null");
+			this.headers.put(name, value);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} to be provided access to the headers allowing the ability to
+		 * add, replace, or remove.
+		 * @param headersConsumer a {@code Consumer} of the headers
+		 * @return the {@link Builder}
+		 */
+		Builder headers(Consumer<Map<String, Object>> headersConsumer) {
+			headersConsumer.accept(this.headers);
+			return this;
+		}
+
+		/**
+		 * Builds a new {@link JoseHeader}.
+		 * @return a {@link JoseHeader}
+		 */
+		JoseHeader build() {
+			Assert.notEmpty(this.headers, "headers cannot be empty");
+			return new JoseHeader(this.headers);
+		}
+
+		private static URL convertAsURL(String header, String value) {
+			URL convertedValue = ClaimConversionService.getSharedInstance().convert(value, URL.class);
+			Assert.isTrue(convertedValue != null,
+					() -> "Unable to convert header '" + header + "' of type '" + value.getClass() + "' to URL.");
+			return convertedValue;
+		}
+
+	}
+
+}

+ 127 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeaderNames.java

@@ -0,0 +1,127 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * The Registered Header Parameter Names defined by the JSON Web Token (JWT), JSON Web
+ * Signature (JWS) and JSON Web Encryption (JWE) specifications that may be contained in
+ * the JOSE Header of a JWT.
+ *
+ * @author Anoop Garlapati
+ * @author Joe Grandja
+ * @since 5.5
+ * @see JoseHeader
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7519#section-5">JWT JOSE
+ * Header</a>
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7515#section-4">JWS JOSE
+ * Header</a>
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7516#section-4">JWE JOSE
+ * Header</a>
+ */
+final class JoseHeaderNames {
+
+	/**
+	 * {@code alg} - the algorithm header identifies the cryptographic algorithm used to
+	 * secure a JWS or JWE
+	 */
+	static final String ALG = "alg";
+
+	/**
+	 * {@code jku} - the JWK Set URL header is a URI that refers to a resource for a set
+	 * of JSON-encoded public keys, one of which corresponds to the key used to digitally
+	 * sign a JWS or encrypt a JWE
+	 */
+	static final String JKU = "jku";
+
+	/**
+	 * {@code jwk} - the JSON Web Key header is the public key that corresponds to the key
+	 * used to digitally sign a JWS or encrypt a JWE
+	 */
+	static final String JWK = "jwk";
+
+	/**
+	 * {@code kid} - the key ID header is a hint indicating which key was used to secure a
+	 * JWS or JWE
+	 */
+	static final String KID = "kid";
+
+	/**
+	 * {@code x5u} - the X.509 URL header is a URI that refers to a resource for the X.509
+	 * public key certificate or certificate chain corresponding to the key used to
+	 * digitally sign a JWS or encrypt a JWE
+	 */
+	static final String X5U = "x5u";
+
+	/**
+	 * {@code x5c} - the X.509 certificate chain header contains the X.509 public key
+	 * certificate or certificate chain corresponding to the key used to digitally sign a
+	 * JWS or encrypt a JWE
+	 */
+	static final String X5C = "x5c";
+
+	/**
+	 * {@code x5t} - the X.509 certificate SHA-1 thumbprint header is a base64url-encoded
+	 * SHA-1 thumbprint (a.k.a. digest) of the DER encoding of the X.509 certificate
+	 * corresponding to the key used to digitally sign a JWS or encrypt a JWE
+	 */
+	static final String X5T = "x5t";
+
+	/**
+	 * {@code x5t#S256} - the X.509 certificate SHA-256 thumbprint header is a
+	 * base64url-encoded SHA-256 thumbprint (a.k.a. digest) of the DER encoding of the
+	 * X.509 certificate corresponding to the key used to digitally sign a JWS or encrypt
+	 * a JWE
+	 */
+	static final String X5T_S256 = "x5t#S256";
+
+	/**
+	 * {@code typ} - the type header is used by JWS/JWE applications to declare the media
+	 * type of a JWS/JWE
+	 */
+	static final String TYP = "typ";
+
+	/**
+	 * {@code cty} - the content type header is used by JWS/JWE applications to declare
+	 * the media type of the secured content (the payload)
+	 */
+	static final String CTY = "cty";
+
+	/**
+	 * {@code crit} - the critical header indicates that extensions to the JWS/JWE/JWA
+	 * specifications are being used that MUST be understood and processed
+	 */
+	static final String CRIT = "crit";
+
+	private JoseHeaderNames() {
+	}
+
+}

+ 222 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSet.java

@@ -0,0 +1,222 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import java.net.URL;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimAccessor;
+import org.springframework.security.oauth2.jwt.JwtClaimNames;
+import org.springframework.util.Assert;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * The {@link Jwt JWT} Claims Set is a JSON object representing the claims conveyed by a
+ * JSON Web Token.
+ *
+ * @author Anoop Garlapati
+ * @author Joe Grandja
+ * @since 5.5
+ * @see Jwt
+ * @see JwtClaimAccessor
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7519#section-4">JWT Claims
+ * Set</a>
+ */
+final class JwtClaimsSet implements JwtClaimAccessor {
+
+	private final Map<String, Object> claims;
+
+	private JwtClaimsSet(Map<String, Object> claims) {
+		this.claims = Collections.unmodifiableMap(new HashMap<>(claims));
+	}
+
+	@Override
+	public Map<String, Object> getClaims() {
+		return this.claims;
+	}
+
+	/**
+	 * Returns a new {@link Builder}.
+	 * @return the {@link Builder}
+	 */
+	static Builder builder() {
+		return new Builder();
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the provided {@code claims}.
+	 * @param claims a JWT claims set
+	 * @return the {@link Builder}
+	 */
+	static Builder from(JwtClaimsSet claims) {
+		return new Builder(claims);
+	}
+
+	/**
+	 * A builder for {@link JwtClaimsSet}.
+	 */
+	static final class Builder {
+
+		final Map<String, Object> claims = new HashMap<>();
+
+		private Builder() {
+		}
+
+		private Builder(JwtClaimsSet claims) {
+			Assert.notNull(claims, "claims cannot be null");
+			this.claims.putAll(claims.getClaims());
+		}
+
+		/**
+		 * Sets the issuer {@code (iss)} claim, which identifies the principal that issued
+		 * the JWT.
+		 * @param issuer the issuer identifier
+		 * @return the {@link Builder}
+		 */
+		Builder issuer(String issuer) {
+			return claim(JwtClaimNames.ISS, issuer);
+		}
+
+		/**
+		 * Sets the subject {@code (sub)} claim, which identifies the principal that is
+		 * the subject of the JWT.
+		 * @param subject the subject identifier
+		 * @return the {@link Builder}
+		 */
+		Builder subject(String subject) {
+			return claim(JwtClaimNames.SUB, subject);
+		}
+
+		/**
+		 * Sets the audience {@code (aud)} claim, which identifies the recipient(s) that
+		 * the JWT is intended for.
+		 * @param audience the audience that this JWT is intended for
+		 * @return the {@link Builder}
+		 */
+		Builder audience(List<String> audience) {
+			return claim(JwtClaimNames.AUD, audience);
+		}
+
+		/**
+		 * Sets the expiration time {@code (exp)} claim, which identifies the time on or
+		 * after which the JWT MUST NOT be accepted for processing.
+		 * @param expiresAt the time on or after which the JWT MUST NOT be accepted for
+		 * processing
+		 * @return the {@link Builder}
+		 */
+		Builder expiresAt(Instant expiresAt) {
+			return claim(JwtClaimNames.EXP, expiresAt);
+		}
+
+		/**
+		 * Sets the not before {@code (nbf)} claim, which identifies the time before which
+		 * the JWT MUST NOT be accepted for processing.
+		 * @param notBefore the time before which the JWT MUST NOT be accepted for
+		 * processing
+		 * @return the {@link Builder}
+		 */
+		Builder notBefore(Instant notBefore) {
+			return claim(JwtClaimNames.NBF, notBefore);
+		}
+
+		/**
+		 * Sets the issued at {@code (iat)} claim, which identifies the time at which the
+		 * JWT was issued.
+		 * @param issuedAt the time at which the JWT was issued
+		 * @return the {@link Builder}
+		 */
+		Builder issuedAt(Instant issuedAt) {
+			return claim(JwtClaimNames.IAT, issuedAt);
+		}
+
+		/**
+		 * Sets the JWT ID {@code (jti)} claim, which provides a unique identifier for the
+		 * JWT.
+		 * @param jti the unique identifier for the JWT
+		 * @return the {@link Builder}
+		 */
+		Builder id(String jti) {
+			return claim(JwtClaimNames.JTI, jti);
+		}
+
+		/**
+		 * Sets the claim.
+		 * @param name the claim name
+		 * @param value the claim value
+		 * @return the {@link Builder}
+		 */
+		Builder claim(String name, Object value) {
+			Assert.hasText(name, "name cannot be empty");
+			Assert.notNull(value, "value cannot be null");
+			this.claims.put(name, value);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} to be provided access to the claims allowing the ability to
+		 * add, replace, or remove.
+		 * @param claimsConsumer a {@code Consumer} of the claims
+		 */
+		Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
+			claimsConsumer.accept(this.claims);
+			return this;
+		}
+
+		/**
+		 * Builds a new {@link JwtClaimsSet}.
+		 * @return a {@link JwtClaimsSet}
+		 */
+		JwtClaimsSet build() {
+			Assert.notEmpty(this.claims, "claims cannot be empty");
+
+			// The value of the 'iss' claim is a String or URL (StringOrURI).
+			// Attempt to convert to URL.
+			Object issuer = this.claims.get(JwtClaimNames.ISS);
+			if (issuer != null) {
+				URL convertedValue = ClaimConversionService.getSharedInstance().convert(issuer, URL.class);
+				if (convertedValue != null) {
+					this.claims.put(JwtClaimNames.ISS, convertedValue);
+				}
+			}
+
+			return new JwtClaimsSet(this.claims);
+		}
+
+	}
+
+}

+ 62 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtEncodingException.java

@@ -0,0 +1,62 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import org.springframework.security.oauth2.jwt.JwtException;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * This exception is thrown when an error occurs while attempting to encode a JSON Web
+ * Token (JWT).
+ *
+ * @author Joe Grandja
+ * @since 5.5
+ */
+class JwtEncodingException extends JwtException {
+
+	/**
+	 * Constructs a {@code JwtEncodingException} using the provided parameters.
+	 * @param message the detail message
+	 */
+	JwtEncodingException(String message) {
+		super(message);
+	}
+
+	/**
+	 * Constructs a {@code JwtEncodingException} using the provided parameters.
+	 * @param message the detail message
+	 * @param cause the root cause
+	 */
+	JwtEncodingException(String message, Throwable cause) {
+		super(message, cause);
+	}
+
+}

+ 359 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoder.java

@@ -0,0 +1,359 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import java.net.URI;
+import java.net.URL;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSSigner;
+import com.nimbusds.jose.crypto.factories.DefaultJWSSignerFactory;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKMatcher;
+import com.nimbusds.jose.jwk.JWKSelector;
+import com.nimbusds.jose.jwk.KeyType;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jose.produce.JWSSignerFactory;
+import com.nimbusds.jose.util.Base64;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimNames;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * A JWT encoder that encodes a JSON Web Token (JWT) using the JSON Web Signature (JWS)
+ * Compact Serialization format. The private/secret key used for signing the JWS is
+ * supplied by the {@code com.nimbusds.jose.jwk.source.JWKSource} provided via the
+ * constructor.
+ *
+ * <p>
+ * <b>NOTE:</b> This implementation uses the Nimbus JOSE + JWT SDK.
+ *
+ * @author Joe Grandja
+ * @since 5.5
+ * @see com.nimbusds.jose.jwk.source.JWKSource
+ * @see com.nimbusds.jose.jwk.JWK
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7519">JSON Web Token
+ * (JWT)</a>
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7515">JSON Web Signature
+ * (JWS)</a>
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7515#section-3.1">JWS
+ * Compact Serialization</a>
+ * @see <a target="_blank" href="https://connect2id.com/products/nimbus-jose-jwt">Nimbus
+ * JOSE + JWT SDK</a>
+ */
+final class NimbusJwsEncoder {
+
+	private static final String ENCODING_ERROR_MESSAGE_TEMPLATE = "An error occurred while attempting to encode the Jwt: %s";
+
+	private static final JWSSignerFactory JWS_SIGNER_FACTORY = new DefaultJWSSignerFactory();
+
+	private final Map<JWK, JWSSigner> jwsSigners = new ConcurrentHashMap<>();
+
+	private final JWKSource<SecurityContext> jwkSource;
+
+	/**
+	 * Constructs a {@code NimbusJwsEncoder} using the provided parameters.
+	 * @param jwkSource the {@code com.nimbusds.jose.jwk.source.JWKSource}
+	 */
+	NimbusJwsEncoder(JWKSource<SecurityContext> jwkSource) {
+		Assert.notNull(jwkSource, "jwkSource cannot be null");
+		this.jwkSource = jwkSource;
+	}
+
+	Jwt encode(JoseHeader headers, JwtClaimsSet claims) throws JwtEncodingException {
+		Assert.notNull(headers, "headers cannot be null");
+		Assert.notNull(claims, "claims cannot be null");
+
+		JWK jwk = selectJwk(headers);
+		headers = addKeyIdentifierHeadersIfNecessary(headers, jwk);
+
+		String jws = serialize(headers, claims, jwk);
+
+		return new Jwt(jws, claims.getIssuedAt(), claims.getExpiresAt(), headers.getHeaders(), claims.getClaims());
+	}
+
+	private JWK selectJwk(JoseHeader headers) {
+		List<JWK> jwks;
+		try {
+			JWKSelector jwkSelector = new JWKSelector(createJwkMatcher(headers));
+			jwks = this.jwkSource.get(jwkSelector, null);
+		}
+		catch (Exception ex) {
+			throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE,
+					"Failed to select a JWK signing key -> " + ex.getMessage()), ex);
+		}
+
+		if (jwks.size() > 1) {
+			throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE,
+					"Found multiple JWK signing keys for algorithm '" + headers.getAlgorithm().getName() + "'"));
+		}
+
+		if (jwks.isEmpty()) {
+			throw new JwtEncodingException(
+					String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Failed to select a JWK signing key"));
+		}
+
+		return jwks.get(0);
+	}
+
+	private String serialize(JoseHeader headers, JwtClaimsSet claims, JWK jwk) {
+		JWSHeader jwsHeader = convert(headers);
+		JWTClaimsSet jwtClaimsSet = convert(claims);
+
+		JWSSigner jwsSigner = this.jwsSigners.computeIfAbsent(jwk, NimbusJwsEncoder::createSigner);
+
+		SignedJWT signedJwt = new SignedJWT(jwsHeader, jwtClaimsSet);
+		try {
+			signedJwt.sign(jwsSigner);
+		}
+		catch (JOSEException ex) {
+			throw new JwtEncodingException(
+					String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Failed to sign the JWT -> " + ex.getMessage()), ex);
+		}
+		return signedJwt.serialize();
+	}
+
+	private static JWKMatcher createJwkMatcher(JoseHeader headers) {
+		JWSAlgorithm jwsAlgorithm = JWSAlgorithm.parse(headers.getAlgorithm().getName());
+
+		if (JWSAlgorithm.Family.RSA.contains(jwsAlgorithm) || JWSAlgorithm.Family.EC.contains(jwsAlgorithm)) {
+			// @formatter:off
+			return new JWKMatcher.Builder()
+					.keyType(KeyType.forAlgorithm(jwsAlgorithm))
+					.keyID(headers.getKeyId())
+					.keyUses(KeyUse.SIGNATURE, null)
+					.algorithms(jwsAlgorithm, null)
+					.x509CertSHA256Thumbprint(Base64URL.from(headers.getX509SHA256Thumbprint()))
+					.build();
+			// @formatter:on
+		}
+		else if (JWSAlgorithm.Family.HMAC_SHA.contains(jwsAlgorithm)) {
+			// @formatter:off
+			return new JWKMatcher.Builder()
+					.keyType(KeyType.forAlgorithm(jwsAlgorithm))
+					.keyID(headers.getKeyId())
+					.privateOnly(true)
+					.algorithms(jwsAlgorithm, null)
+					.build();
+			// @formatter:on
+		}
+
+		return null;
+	}
+
+	private static JoseHeader addKeyIdentifierHeadersIfNecessary(JoseHeader headers, JWK jwk) {
+		// Check if headers have already been added
+		if (StringUtils.hasText(headers.getKeyId()) && StringUtils.hasText(headers.getX509SHA256Thumbprint())) {
+			return headers;
+		}
+		// Check if headers can be added from JWK
+		if (!StringUtils.hasText(jwk.getKeyID()) && jwk.getX509CertSHA256Thumbprint() == null) {
+			return headers;
+		}
+
+		JoseHeader.Builder headersBuilder = JoseHeader.from(headers);
+		if (!StringUtils.hasText(headers.getKeyId()) && StringUtils.hasText(jwk.getKeyID())) {
+			headersBuilder.keyId(jwk.getKeyID());
+		}
+		if (!StringUtils.hasText(headers.getX509SHA256Thumbprint()) && jwk.getX509CertSHA256Thumbprint() != null) {
+			headersBuilder.x509SHA256Thumbprint(jwk.getX509CertSHA256Thumbprint().toString());
+		}
+
+		return headersBuilder.build();
+	}
+
+	private static JWSSigner createSigner(JWK jwk) {
+		try {
+			return JWS_SIGNER_FACTORY.createJWSSigner(jwk);
+		}
+		catch (JOSEException ex) {
+			throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE,
+					"Failed to create a JWS Signer -> " + ex.getMessage()), ex);
+		}
+	}
+
+	private static JWSHeader convert(JoseHeader headers) {
+		JWSHeader.Builder builder = new JWSHeader.Builder(JWSAlgorithm.parse(headers.getAlgorithm().getName()));
+
+		if (headers.getJwkSetUrl() != null) {
+			builder.jwkURL(convertAsURI(JoseHeaderNames.JKU, headers.getJwkSetUrl()));
+		}
+
+		Map<String, Object> jwk = headers.getJwk();
+		if (!CollectionUtils.isEmpty(jwk)) {
+			try {
+				builder.jwk(JWK.parse(jwk));
+			}
+			catch (Exception ex) {
+				throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE,
+						"Unable to convert '" + JoseHeaderNames.JWK + "' JOSE header"), ex);
+			}
+		}
+
+		String keyId = headers.getKeyId();
+		if (StringUtils.hasText(keyId)) {
+			builder.keyID(keyId);
+		}
+
+		if (headers.getX509Url() != null) {
+			builder.x509CertURL(convertAsURI(JoseHeaderNames.X5U, headers.getX509Url()));
+		}
+
+		List<String> x509CertificateChain = headers.getX509CertificateChain();
+		if (!CollectionUtils.isEmpty(x509CertificateChain)) {
+			List<Base64> x5cList = new ArrayList<>();
+			x509CertificateChain.forEach((x5c) -> x5cList.add(new Base64(x5c)));
+			if (!x5cList.isEmpty()) {
+				builder.x509CertChain(x5cList);
+			}
+		}
+
+		String x509SHA1Thumbprint = headers.getX509SHA1Thumbprint();
+		if (StringUtils.hasText(x509SHA1Thumbprint)) {
+			builder.x509CertThumbprint(new Base64URL(x509SHA1Thumbprint));
+		}
+
+		String x509SHA256Thumbprint = headers.getX509SHA256Thumbprint();
+		if (StringUtils.hasText(x509SHA256Thumbprint)) {
+			builder.x509CertSHA256Thumbprint(new Base64URL(x509SHA256Thumbprint));
+		}
+
+		String type = headers.getType();
+		if (StringUtils.hasText(type)) {
+			builder.type(new JOSEObjectType(type));
+		}
+
+		String contentType = headers.getContentType();
+		if (StringUtils.hasText(contentType)) {
+			builder.contentType(contentType);
+		}
+
+		Set<String> critical = headers.getCritical();
+		if (!CollectionUtils.isEmpty(critical)) {
+			builder.criticalParams(critical);
+		}
+
+		Map<String, Object> customHeaders = new HashMap<>();
+		headers.getHeaders().forEach((name, value) -> {
+			if (!JWSHeader.getRegisteredParameterNames().contains(name)) {
+				customHeaders.put(name, value);
+			}
+		});
+		if (!customHeaders.isEmpty()) {
+			builder.customParams(customHeaders);
+		}
+
+		return builder.build();
+	}
+
+	private static JWTClaimsSet convert(JwtClaimsSet claims) {
+		JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder();
+
+		// NOTE: The value of the 'iss' claim is a String or URL (StringOrURI).
+		Object issuer = claims.getClaim(JwtClaimNames.ISS);
+		if (issuer != null) {
+			builder.issuer(issuer.toString());
+		}
+
+		String subject = claims.getSubject();
+		if (StringUtils.hasText(subject)) {
+			builder.subject(subject);
+		}
+
+		List<String> audience = claims.getAudience();
+		if (!CollectionUtils.isEmpty(audience)) {
+			builder.audience(audience);
+		}
+
+		Instant expiresAt = claims.getExpiresAt();
+		if (expiresAt != null) {
+			builder.expirationTime(Date.from(expiresAt));
+		}
+
+		Instant notBefore = claims.getNotBefore();
+		if (notBefore != null) {
+			builder.notBeforeTime(Date.from(notBefore));
+		}
+
+		Instant issuedAt = claims.getIssuedAt();
+		if (issuedAt != null) {
+			builder.issueTime(Date.from(issuedAt));
+		}
+
+		String jwtId = claims.getId();
+		if (StringUtils.hasText(jwtId)) {
+			builder.jwtID(jwtId);
+		}
+
+		Map<String, Object> customClaims = new HashMap<>();
+		claims.getClaims().forEach((name, value) -> {
+			if (!JWTClaimsSet.getRegisteredNames().contains(name)) {
+				customClaims.put(name, value);
+			}
+		});
+		if (!customClaims.isEmpty()) {
+			customClaims.forEach(builder::claim);
+		}
+
+		return builder.build();
+	}
+
+	private static URI convertAsURI(String header, URL url) {
+		try {
+			return url.toURI();
+		}
+		catch (Exception ex) {
+			throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE,
+					"Unable to convert '" + header + "' JOSE header to a URI"), ex);
+		}
+	}
+
+}

+ 183 - 0
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverter.java

@@ -0,0 +1,183 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.KeyType;
+import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.util.Assert;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+/**
+ * A {@link Converter} that customizes the OAuth 2.0 Access Token Request parameters by
+ * adding a signed JSON Web Token (JWS) to be used for client authentication at the
+ * Authorization Server's Token Endpoint. The private/secret key used for signing the JWS
+ * is supplied by the {@code com.nimbusds.jose.jwk.JWK} resolver provided via the
+ * constructor.
+ *
+ * <p>
+ * <b>NOTE:</b> This implementation uses the Nimbus JOSE + JWT SDK.
+ *
+ * @param <T> the type of {@link AbstractOAuth2AuthorizationGrantRequest}
+ * @author Joe Grandja
+ * @since 5.5
+ * @see Converter
+ * @see com.nimbusds.jose.jwk.JWK
+ * @see OAuth2AuthorizationCodeGrantRequestEntityConverter#addParametersConverter(Converter)
+ * @see OAuth2ClientCredentialsGrantRequestEntityConverter#addParametersConverter(Converter)
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7523#section-2.2">2.2
+ * Using JWTs for Client Authentication</a>
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7521#section-4.2">4.2
+ * Using Assertions for Client Authentication</a>
+ * @see <a target="_blank" href="https://connect2id.com/products/nimbus-jose-jwt">Nimbus
+ * JOSE + JWT SDK</a>
+ */
+public final class NimbusJwtClientAuthenticationParametersConverter<T extends AbstractOAuth2AuthorizationGrantRequest>
+		implements Converter<T, MultiValueMap<String, String>> {
+
+	private static final String INVALID_KEY_ERROR_CODE = "invalid_key";
+
+	private static final String INVALID_ALGORITHM_ERROR_CODE = "invalid_algorithm";
+
+	private static final String CLIENT_ASSERTION_TYPE_VALUE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
+
+	private final Function<ClientRegistration, JWK> jwkResolver;
+
+	private final Map<String, NimbusJwsEncoder> jwsEncoders = new ConcurrentHashMap<>();
+
+	/**
+	 * Constructs a {@code NimbusJwtClientAuthenticationParametersConverter} using the
+	 * provided parameters.
+	 * @param jwkResolver the resolver that provides the {@code com.nimbusds.jose.jwk.JWK}
+	 * associated to the {@link ClientRegistration client}
+	 */
+	public NimbusJwtClientAuthenticationParametersConverter(Function<ClientRegistration, JWK> jwkResolver) {
+		Assert.notNull(jwkResolver, "jwkResolver cannot be null");
+		this.jwkResolver = jwkResolver;
+	}
+
+	@Override
+	public MultiValueMap<String, String> convert(T authorizationGrantRequest) {
+		Assert.notNull(authorizationGrantRequest, "authorizationGrantRequest cannot be null");
+
+		ClientRegistration clientRegistration = authorizationGrantRequest.getClientRegistration();
+		if (!ClientAuthenticationMethod.PRIVATE_KEY_JWT.equals(clientRegistration.getClientAuthenticationMethod())
+				&& !ClientAuthenticationMethod.CLIENT_SECRET_JWT
+						.equals(clientRegistration.getClientAuthenticationMethod())) {
+			return null;
+		}
+
+		JWK jwk = this.jwkResolver.apply(clientRegistration);
+		if (jwk == null) {
+			OAuth2Error oauth2Error = new OAuth2Error(INVALID_KEY_ERROR_CODE,
+					"Failed to resolve JWK signing key for client registration '"
+							+ clientRegistration.getRegistrationId() + "'.",
+					null);
+			throw new OAuth2AuthorizationException(oauth2Error);
+		}
+
+		JwsAlgorithm jwsAlgorithm = resolveAlgorithm(jwk);
+		if (jwsAlgorithm == null) {
+			OAuth2Error oauth2Error = new OAuth2Error(INVALID_ALGORITHM_ERROR_CODE,
+					"Unable to resolve JWS (signing) algorithm from JWK associated to client registration '"
+							+ clientRegistration.getRegistrationId() + "'.",
+					null);
+			throw new OAuth2AuthorizationException(oauth2Error);
+		}
+
+		JoseHeader.Builder headersBuilder = JoseHeader.withAlgorithm(jwsAlgorithm);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(Duration.ofSeconds(60));
+
+		// @formatter:off
+		JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder()
+				.issuer(clientRegistration.getClientId())
+				.subject(clientRegistration.getClientId())
+				.audience(Collections.singletonList(clientRegistration.getProviderDetails().getTokenUri()))
+				.id(UUID.randomUUID().toString())
+				.issuedAt(issuedAt)
+				.expiresAt(expiresAt);
+		// @formatter:on
+
+		JoseHeader joseHeader = headersBuilder.build();
+		JwtClaimsSet jwtClaimsSet = claimsBuilder.build();
+
+		NimbusJwsEncoder jwsEncoder = this.jwsEncoders.computeIfAbsent(clientRegistration.getRegistrationId(),
+				(clientRegistrationId) -> {
+					JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk));
+					return new NimbusJwsEncoder(jwkSource);
+				});
+
+		Jwt jws = jwsEncoder.encode(joseHeader, jwtClaimsSet);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE, CLIENT_ASSERTION_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.CLIENT_ASSERTION, jws.getTokenValue());
+
+		return parameters;
+	}
+
+	private static JwsAlgorithm resolveAlgorithm(JWK jwk) {
+		JwsAlgorithm jwsAlgorithm = null;
+
+		if (jwk.getAlgorithm() != null) {
+			jwsAlgorithm = SignatureAlgorithm.from(jwk.getAlgorithm().getName());
+			if (jwsAlgorithm == null) {
+				jwsAlgorithm = MacAlgorithm.from(jwk.getAlgorithm().getName());
+			}
+		}
+
+		if (jwsAlgorithm == null) {
+			if (KeyType.RSA.equals(jwk.getKeyType())) {
+				jwsAlgorithm = SignatureAlgorithm.RS256;
+			}
+			else if (KeyType.EC.equals(jwk.getKeyType())) {
+				jwsAlgorithm = SignatureAlgorithm.ES256;
+			}
+			else if (KeyType.OCT.equals(jwk.getKeyType())) {
+				jwsAlgorithm = MacAlgorithm.HS256;
+			}
+		}
+
+		return jwsAlgorithm;
+	}
+
+}

+ 2 - 14
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequest.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2017 the original author or authors.
+ * Copyright 2002-2021 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.
@@ -37,8 +37,6 @@ import org.springframework.util.Assert;
  */
 public class OAuth2AuthorizationCodeGrantRequest extends AbstractOAuth2AuthorizationGrantRequest {
 
-	private final ClientRegistration clientRegistration;
-
 	private final OAuth2AuthorizationExchange authorizationExchange;
 
 	/**
@@ -49,21 +47,11 @@ public class OAuth2AuthorizationCodeGrantRequest extends AbstractOAuth2Authoriza
 	 */
 	public OAuth2AuthorizationCodeGrantRequest(ClientRegistration clientRegistration,
 			OAuth2AuthorizationExchange authorizationExchange) {
-		super(AuthorizationGrantType.AUTHORIZATION_CODE);
-		Assert.notNull(clientRegistration, "clientRegistration cannot be null");
+		super(AuthorizationGrantType.AUTHORIZATION_CODE, clientRegistration);
 		Assert.notNull(authorizationExchange, "authorizationExchange cannot be null");
-		this.clientRegistration = clientRegistration;
 		this.authorizationExchange = authorizationExchange;
 	}
 
-	/**
-	 * Returns the {@link ClientRegistration client registration}.
-	 * @return the {@link ClientRegistration}
-	 */
-	public ClientRegistration getClientRegistration() {
-		return this.clientRegistration;
-	}
-
 	/**
 	 * Returns the {@link OAuth2AuthorizationExchange authorization exchange}.
 	 * @return the {@link OAuth2AuthorizationExchange}

+ 16 - 42
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,11 +16,6 @@
 
 package org.springframework.security.oauth2.client.endpoint;
 
-import java.net.URI;
-
-import org.springframework.core.convert.converter.Converter;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpMethod;
 import org.springframework.http.RequestEntity;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
@@ -29,69 +24,48 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
-import org.springframework.web.util.UriComponentsBuilder;
 
 /**
- * A {@link Converter} that converts the provided
- * {@link OAuth2AuthorizationCodeGrantRequest} to a {@link RequestEntity} representation
- * of an OAuth 2.0 Access Token Request for the Authorization Code Grant.
+ * An implementation of an {@link AbstractOAuth2AuthorizationGrantRequestEntityConverter}
+ * that converts the provided {@link OAuth2AuthorizationCodeGrantRequest} to a
+ * {@link RequestEntity} representation of an OAuth 2.0 Access Token Request for the
+ * Authorization Code Grant.
  *
  * @author Joe Grandja
  * @since 5.1
- * @see Converter
+ * @see AbstractOAuth2AuthorizationGrantRequestEntityConverter
  * @see OAuth2AuthorizationCodeGrantRequest
  * @see RequestEntity
  */
 public class OAuth2AuthorizationCodeGrantRequestEntityConverter
-		implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {
+		extends AbstractOAuth2AuthorizationGrantRequestEntityConverter<OAuth2AuthorizationCodeGrantRequest> {
 
-	/**
-	 * Returns the {@link RequestEntity} used for the Access Token Request.
-	 * @param authorizationCodeGrantRequest the authorization code grant request
-	 * @return the {@link RequestEntity} used for the Access Token Request
-	 */
 	@Override
-	public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
-		ClientRegistration clientRegistration = authorizationCodeGrantRequest.getClientRegistration();
-		HttpHeaders headers = OAuth2AuthorizationGrantRequestEntityUtils.getTokenRequestHeaders(clientRegistration);
-		MultiValueMap<String, String> formParameters = this.buildFormParameters(authorizationCodeGrantRequest);
-		URI uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getTokenUri()).build()
-				.toUri();
-		return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri);
-	}
-
-	/**
-	 * Returns a {@link MultiValueMap} of the form parameters used for the Access Token
-	 * Request body.
-	 * @param authorizationCodeGrantRequest the authorization code grant request
-	 * @return a {@link MultiValueMap} of the form parameters used for the Access Token
-	 * Request body
-	 */
-	private MultiValueMap<String, String> buildFormParameters(
+	protected MultiValueMap<String, String> createParameters(
 			OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
 		ClientRegistration clientRegistration = authorizationCodeGrantRequest.getClientRegistration();
 		OAuth2AuthorizationExchange authorizationExchange = authorizationCodeGrantRequest.getAuthorizationExchange();
-		MultiValueMap<String, String> formParameters = new LinkedMultiValueMap<>();
-		formParameters.add(OAuth2ParameterNames.GRANT_TYPE, authorizationCodeGrantRequest.getGrantType().getValue());
-		formParameters.add(OAuth2ParameterNames.CODE, authorizationExchange.getAuthorizationResponse().getCode());
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.add(OAuth2ParameterNames.GRANT_TYPE, authorizationCodeGrantRequest.getGrantType().getValue());
+		parameters.add(OAuth2ParameterNames.CODE, authorizationExchange.getAuthorizationResponse().getCode());
 		String redirectUri = authorizationExchange.getAuthorizationRequest().getRedirectUri();
 		String codeVerifier = authorizationExchange.getAuthorizationRequest()
 				.getAttribute(PkceParameterNames.CODE_VERIFIER);
 		if (redirectUri != null) {
-			formParameters.add(OAuth2ParameterNames.REDIRECT_URI, redirectUri);
+			parameters.add(OAuth2ParameterNames.REDIRECT_URI, redirectUri);
 		}
 		if (!ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(clientRegistration.getClientAuthenticationMethod())
 				&& !ClientAuthenticationMethod.BASIC.equals(clientRegistration.getClientAuthenticationMethod())) {
-			formParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
+			parameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
 		}
 		if (ClientAuthenticationMethod.CLIENT_SECRET_POST.equals(clientRegistration.getClientAuthenticationMethod())
 				|| ClientAuthenticationMethod.POST.equals(clientRegistration.getClientAuthenticationMethod())) {
-			formParameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
+			parameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
 		}
 		if (codeVerifier != null) {
-			formParameters.add(PkceParameterNames.CODE_VERIFIER, codeVerifier);
+			parameters.add(PkceParameterNames.CODE_VERIFIER, codeVerifier);
 		}
-		return formParameters;
+		return parameters;
 	}
 
 }

+ 2 - 14
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequest.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2018 the original author or authors.
+ * Copyright 2002-2021 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,27 +34,15 @@ import org.springframework.util.Assert;
  */
 public class OAuth2ClientCredentialsGrantRequest extends AbstractOAuth2AuthorizationGrantRequest {
 
-	private final ClientRegistration clientRegistration;
-
 	/**
 	 * Constructs an {@code OAuth2ClientCredentialsGrantRequest} using the provided
 	 * parameters.
 	 * @param clientRegistration the client registration
 	 */
 	public OAuth2ClientCredentialsGrantRequest(ClientRegistration clientRegistration) {
-		super(AuthorizationGrantType.CLIENT_CREDENTIALS);
-		Assert.notNull(clientRegistration, "clientRegistration cannot be null");
+		super(AuthorizationGrantType.CLIENT_CREDENTIALS, clientRegistration);
 		Assert.isTrue(AuthorizationGrantType.CLIENT_CREDENTIALS.equals(clientRegistration.getAuthorizationGrantType()),
 				"clientRegistration.authorizationGrantType must be AuthorizationGrantType.CLIENT_CREDENTIALS");
-		this.clientRegistration = clientRegistration;
-	}
-
-	/**
-	 * Returns the {@link ClientRegistration client registration}.
-	 * @return the {@link ClientRegistration}
-	 */
-	public ClientRegistration getClientRegistration() {
-		return this.clientRegistration;
 	}
 
 }

+ 14 - 40
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityConverter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,11 +16,6 @@
 
 package org.springframework.security.oauth2.client.endpoint;
 
-import java.net.URI;
-
-import org.springframework.core.convert.converter.Converter;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpMethod;
 import org.springframework.http.RequestEntity;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
@@ -29,59 +24,38 @@ import org.springframework.util.CollectionUtils;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 import org.springframework.util.StringUtils;
-import org.springframework.web.util.UriComponentsBuilder;
 
 /**
- * A {@link Converter} that converts the provided
- * {@link OAuth2ClientCredentialsGrantRequest} to a {@link RequestEntity} representation
- * of an OAuth 2.0 Access Token Request for the Client Credentials Grant.
+ * An implementation of an {@link AbstractOAuth2AuthorizationGrantRequestEntityConverter}
+ * that converts the provided {@link OAuth2ClientCredentialsGrantRequest} to a
+ * {@link RequestEntity} representation of an OAuth 2.0 Access Token Request for the
+ * Client Credentials Grant.
  *
  * @author Joe Grandja
  * @since 5.1
- * @see Converter
+ * @see AbstractOAuth2AuthorizationGrantRequestEntityConverter
  * @see OAuth2ClientCredentialsGrantRequest
  * @see RequestEntity
  */
 public class OAuth2ClientCredentialsGrantRequestEntityConverter
-		implements Converter<OAuth2ClientCredentialsGrantRequest, RequestEntity<?>> {
+		extends AbstractOAuth2AuthorizationGrantRequestEntityConverter<OAuth2ClientCredentialsGrantRequest> {
 
-	/**
-	 * Returns the {@link RequestEntity} used for the Access Token Request.
-	 * @param clientCredentialsGrantRequest the client credentials grant request
-	 * @return the {@link RequestEntity} used for the Access Token Request
-	 */
 	@Override
-	public RequestEntity<?> convert(OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest) {
-		ClientRegistration clientRegistration = clientCredentialsGrantRequest.getClientRegistration();
-		HttpHeaders headers = OAuth2AuthorizationGrantRequestEntityUtils.getTokenRequestHeaders(clientRegistration);
-		MultiValueMap<String, String> formParameters = this.buildFormParameters(clientCredentialsGrantRequest);
-		URI uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getTokenUri()).build()
-				.toUri();
-		return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri);
-	}
-
-	/**
-	 * Returns a {@link MultiValueMap} of the form parameters used for the Access Token
-	 * Request body.
-	 * @param clientCredentialsGrantRequest the client credentials grant request
-	 * @return a {@link MultiValueMap} of the form parameters used for the Access Token
-	 * Request body
-	 */
-	private MultiValueMap<String, String> buildFormParameters(
+	protected MultiValueMap<String, String> createParameters(
 			OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest) {
 		ClientRegistration clientRegistration = clientCredentialsGrantRequest.getClientRegistration();
-		MultiValueMap<String, String> formParameters = new LinkedMultiValueMap<>();
-		formParameters.add(OAuth2ParameterNames.GRANT_TYPE, clientCredentialsGrantRequest.getGrantType().getValue());
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.add(OAuth2ParameterNames.GRANT_TYPE, clientCredentialsGrantRequest.getGrantType().getValue());
 		if (!CollectionUtils.isEmpty(clientRegistration.getScopes())) {
-			formParameters.add(OAuth2ParameterNames.SCOPE,
+			parameters.add(OAuth2ParameterNames.SCOPE,
 					StringUtils.collectionToDelimitedString(clientRegistration.getScopes(), " "));
 		}
 		if (ClientAuthenticationMethod.CLIENT_SECRET_POST.equals(clientRegistration.getClientAuthenticationMethod())
 				|| ClientAuthenticationMethod.POST.equals(clientRegistration.getClientAuthenticationMethod())) {
-			formParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
-			formParameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
+			parameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
+			parameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
 		}
-		return formParameters;
+		return parameters;
 	}
 
 }

+ 2 - 14
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequest.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2019 the original author or authors.
+ * Copyright 2002-2021 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.
@@ -33,8 +33,6 @@ import org.springframework.util.Assert;
  */
 public class OAuth2PasswordGrantRequest extends AbstractOAuth2AuthorizationGrantRequest {
 
-	private final ClientRegistration clientRegistration;
-
 	private final String username;
 
 	private final String password;
@@ -46,25 +44,15 @@ public class OAuth2PasswordGrantRequest extends AbstractOAuth2AuthorizationGrant
 	 * @param password the resource owner's password
 	 */
 	public OAuth2PasswordGrantRequest(ClientRegistration clientRegistration, String username, String password) {
-		super(AuthorizationGrantType.PASSWORD);
-		Assert.notNull(clientRegistration, "clientRegistration cannot be null");
+		super(AuthorizationGrantType.PASSWORD, clientRegistration);
 		Assert.isTrue(AuthorizationGrantType.PASSWORD.equals(clientRegistration.getAuthorizationGrantType()),
 				"clientRegistration.authorizationGrantType must be AuthorizationGrantType.PASSWORD");
 		Assert.hasText(username, "username cannot be empty");
 		Assert.hasText(password, "password cannot be empty");
-		this.clientRegistration = clientRegistration;
 		this.username = username;
 		this.password = password;
 	}
 
-	/**
-	 * Returns the {@link ClientRegistration client registration}.
-	 * @return the {@link ClientRegistration}
-	 */
-	public ClientRegistration getClientRegistration() {
-		return this.clientRegistration;
-	}
-
 	/**
 	 * Returns the resource owner's username.
 	 * @return the resource owner's username

+ 14 - 40
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequestEntityConverter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,11 +16,6 @@
 
 package org.springframework.security.oauth2.client.endpoint;
 
-import java.net.URI;
-
-import org.springframework.core.convert.converter.Converter;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpMethod;
 import org.springframework.http.RequestEntity;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
@@ -29,60 +24,39 @@ import org.springframework.util.CollectionUtils;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 import org.springframework.util.StringUtils;
-import org.springframework.web.util.UriComponentsBuilder;
 
 /**
- * A {@link Converter} that converts the provided {@link OAuth2PasswordGrantRequest} to a
+ * An implementation of an {@link AbstractOAuth2AuthorizationGrantRequestEntityConverter}
+ * that converts the provided {@link OAuth2PasswordGrantRequest} to a
  * {@link RequestEntity} representation of an OAuth 2.0 Access Token Request for the
  * Resource Owner Password Credentials Grant.
  *
  * @author Joe Grandja
  * @since 5.2
- * @see Converter
+ * @see AbstractOAuth2AuthorizationGrantRequestEntityConverter
  * @see OAuth2PasswordGrantRequest
  * @see RequestEntity
  */
 public class OAuth2PasswordGrantRequestEntityConverter
-		implements Converter<OAuth2PasswordGrantRequest, RequestEntity<?>> {
+		extends AbstractOAuth2AuthorizationGrantRequestEntityConverter<OAuth2PasswordGrantRequest> {
 
-	/**
-	 * Returns the {@link RequestEntity} used for the Access Token Request.
-	 * @param passwordGrantRequest the password grant request
-	 * @return the {@link RequestEntity} used for the Access Token Request
-	 */
 	@Override
-	public RequestEntity<?> convert(OAuth2PasswordGrantRequest passwordGrantRequest) {
-		ClientRegistration clientRegistration = passwordGrantRequest.getClientRegistration();
-		HttpHeaders headers = OAuth2AuthorizationGrantRequestEntityUtils.getTokenRequestHeaders(clientRegistration);
-		MultiValueMap<String, String> formParameters = buildFormParameters(passwordGrantRequest);
-		URI uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getTokenUri()).build()
-				.toUri();
-		return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri);
-	}
-
-	/**
-	 * Returns a {@link MultiValueMap} of the form parameters used for the Access Token
-	 * Request body.
-	 * @param passwordGrantRequest the password grant request
-	 * @return a {@link MultiValueMap} of the form parameters used for the Access Token
-	 * Request body
-	 */
-	private MultiValueMap<String, String> buildFormParameters(OAuth2PasswordGrantRequest passwordGrantRequest) {
+	protected MultiValueMap<String, String> createParameters(OAuth2PasswordGrantRequest passwordGrantRequest) {
 		ClientRegistration clientRegistration = passwordGrantRequest.getClientRegistration();
-		MultiValueMap<String, String> formParameters = new LinkedMultiValueMap<>();
-		formParameters.add(OAuth2ParameterNames.GRANT_TYPE, passwordGrantRequest.getGrantType().getValue());
-		formParameters.add(OAuth2ParameterNames.USERNAME, passwordGrantRequest.getUsername());
-		formParameters.add(OAuth2ParameterNames.PASSWORD, passwordGrantRequest.getPassword());
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.add(OAuth2ParameterNames.GRANT_TYPE, passwordGrantRequest.getGrantType().getValue());
+		parameters.add(OAuth2ParameterNames.USERNAME, passwordGrantRequest.getUsername());
+		parameters.add(OAuth2ParameterNames.PASSWORD, passwordGrantRequest.getPassword());
 		if (!CollectionUtils.isEmpty(clientRegistration.getScopes())) {
-			formParameters.add(OAuth2ParameterNames.SCOPE,
+			parameters.add(OAuth2ParameterNames.SCOPE,
 					StringUtils.collectionToDelimitedString(clientRegistration.getScopes(), " "));
 		}
 		if (ClientAuthenticationMethod.CLIENT_SECRET_POST.equals(clientRegistration.getClientAuthenticationMethod())
 				|| ClientAuthenticationMethod.POST.equals(clientRegistration.getClientAuthenticationMethod())) {
-			formParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
-			formParameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
+			parameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
+			parameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
 		}
-		return formParameters;
+		return parameters;
 	}
 
 }

+ 2 - 14
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2RefreshTokenGrantRequest.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2019 the original author or authors.
+ * Copyright 2002-2021 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.
@@ -39,8 +39,6 @@ import org.springframework.util.Assert;
  */
 public class OAuth2RefreshTokenGrantRequest extends AbstractOAuth2AuthorizationGrantRequest {
 
-	private final ClientRegistration clientRegistration;
-
 	private final OAuth2AccessToken accessToken;
 
 	private final OAuth2RefreshToken refreshToken;
@@ -67,25 +65,15 @@ public class OAuth2RefreshTokenGrantRequest extends AbstractOAuth2AuthorizationG
 	 */
 	public OAuth2RefreshTokenGrantRequest(ClientRegistration clientRegistration, OAuth2AccessToken accessToken,
 			OAuth2RefreshToken refreshToken, Set<String> scopes) {
-		super(AuthorizationGrantType.REFRESH_TOKEN);
-		Assert.notNull(clientRegistration, "clientRegistration cannot be null");
+		super(AuthorizationGrantType.REFRESH_TOKEN, clientRegistration);
 		Assert.notNull(accessToken, "accessToken cannot be null");
 		Assert.notNull(refreshToken, "refreshToken cannot be null");
-		this.clientRegistration = clientRegistration;
 		this.accessToken = accessToken;
 		this.refreshToken = refreshToken;
 		this.scopes = Collections
 				.unmodifiableSet((scopes != null) ? new LinkedHashSet<>(scopes) : Collections.emptySet());
 	}
 
-	/**
-	 * Returns the authorized client's {@link ClientRegistration registration}.
-	 * @return the {@link ClientRegistration}
-	 */
-	public ClientRegistration getClientRegistration() {
-		return this.clientRegistration;
-	}
-
 	/**
 	 * Returns the {@link OAuth2AccessToken access token} credential granted.
 	 * @return the {@link OAuth2AccessToken}

+ 14 - 41
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2RefreshTokenGrantRequestEntityConverter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,11 +16,6 @@
 
 package org.springframework.security.oauth2.client.endpoint;
 
-import java.net.URI;
-
-import org.springframework.core.convert.converter.Converter;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpMethod;
 import org.springframework.http.RequestEntity;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
@@ -29,60 +24,38 @@ import org.springframework.util.CollectionUtils;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 import org.springframework.util.StringUtils;
-import org.springframework.web.util.UriComponentsBuilder;
 
 /**
- * A {@link Converter} that converts the provided {@link OAuth2RefreshTokenGrantRequest}
- * to a {@link RequestEntity} representation of an OAuth 2.0 Access Token Request for the
+ * An implementation of an {@link AbstractOAuth2AuthorizationGrantRequestEntityConverter}
+ * that converts the provided {@link OAuth2RefreshTokenGrantRequest} to a
+ * {@link RequestEntity} representation of an OAuth 2.0 Access Token Request for the
  * Refresh Token Grant.
  *
  * @author Joe Grandja
  * @since 5.2
- * @see Converter
+ * @see AbstractOAuth2AuthorizationGrantRequestEntityConverter
  * @see OAuth2RefreshTokenGrantRequest
  * @see RequestEntity
  */
 public class OAuth2RefreshTokenGrantRequestEntityConverter
-		implements Converter<OAuth2RefreshTokenGrantRequest, RequestEntity<?>> {
+		extends AbstractOAuth2AuthorizationGrantRequestEntityConverter<OAuth2RefreshTokenGrantRequest> {
 
-	/**
-	 * Returns the {@link RequestEntity} used for the Access Token Request.
-	 * @param refreshTokenGrantRequest the refresh token grant request
-	 * @return the {@link RequestEntity} used for the Access Token Request
-	 */
 	@Override
-	public RequestEntity<?> convert(OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest) {
-		ClientRegistration clientRegistration = refreshTokenGrantRequest.getClientRegistration();
-		HttpHeaders headers = OAuth2AuthorizationGrantRequestEntityUtils.getTokenRequestHeaders(clientRegistration);
-		MultiValueMap<String, String> formParameters = buildFormParameters(refreshTokenGrantRequest);
-		URI uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getTokenUri()).build()
-				.toUri();
-		return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri);
-	}
-
-	/**
-	 * Returns a {@link MultiValueMap} of the form parameters used for the Access Token
-	 * Request body.
-	 * @param refreshTokenGrantRequest the refresh token grant request
-	 * @return a {@link MultiValueMap} of the form parameters used for the Access Token
-	 * Request body
-	 */
-	private MultiValueMap<String, String> buildFormParameters(OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest) {
+	protected MultiValueMap<String, String> createParameters(OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest) {
 		ClientRegistration clientRegistration = refreshTokenGrantRequest.getClientRegistration();
-		MultiValueMap<String, String> formParameters = new LinkedMultiValueMap<>();
-		formParameters.add(OAuth2ParameterNames.GRANT_TYPE, refreshTokenGrantRequest.getGrantType().getValue());
-		formParameters.add(OAuth2ParameterNames.REFRESH_TOKEN,
-				refreshTokenGrantRequest.getRefreshToken().getTokenValue());
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.add(OAuth2ParameterNames.GRANT_TYPE, refreshTokenGrantRequest.getGrantType().getValue());
+		parameters.add(OAuth2ParameterNames.REFRESH_TOKEN, refreshTokenGrantRequest.getRefreshToken().getTokenValue());
 		if (!CollectionUtils.isEmpty(refreshTokenGrantRequest.getScopes())) {
-			formParameters.add(OAuth2ParameterNames.SCOPE,
+			parameters.add(OAuth2ParameterNames.SCOPE,
 					StringUtils.collectionToDelimitedString(refreshTokenGrantRequest.getScopes(), " "));
 		}
 		if (ClientAuthenticationMethod.CLIENT_SECRET_POST.equals(clientRegistration.getClientAuthenticationMethod())
 				|| ClientAuthenticationMethod.POST.equals(clientRegistration.getClientAuthenticationMethod())) {
-			formParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
-			formParameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
+			parameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
+			parameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret());
 		}
-		return formParameters;
+		return parameters;
 	}
 
 }

+ 107 - 51
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultAuthorizationCodeTokenResponseClientTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,8 +16,13 @@
 
 package org.springframework.security.oauth2.client.endpoint;
 
+import java.nio.charset.StandardCharsets;
 import java.time.Instant;
+import java.util.function.Function;
 
+import javax.crypto.spec.SecretKeySpec;
+
+import com.nimbusds.jose.jwk.JWK;
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import okhttp3.mockwebserver.RecordedRequest;
@@ -29,7 +34,7 @@ import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
-import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
@@ -37,6 +42,8 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenRespon
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.TestKeys;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -49,32 +56,25 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
  */
 public class DefaultAuthorizationCodeTokenResponseClientTests {
 
-	private DefaultAuthorizationCodeTokenResponseClient tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
+	private DefaultAuthorizationCodeTokenResponseClient tokenResponseClient;
 
-	private ClientRegistration clientRegistration;
+	private ClientRegistration.Builder clientRegistration;
 
 	private MockWebServer server;
 
 	@Before
 	public void setup() throws Exception {
+		this.tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
 		this.server = new MockWebServer();
 		this.server.start();
 		String tokenUri = this.server.url("/oauth2/token").toString();
 		// @formatter:off
-		this.clientRegistration = ClientRegistration
-				.withRegistrationId("registration-1")
+		this.clientRegistration = TestClientRegistrations.clientRegistration()
 				.clientId("client-1")
 				.clientSecret("secret")
-				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
-				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
 				.redirectUri("https://client.com/callback/client-1")
-				.scope("read", "write")
-				.authorizationUri("https://provider.com/oauth2/authorize")
 				.tokenUri(tokenUri)
-				.userInfoUri("https://provider.com/user")
-				.userNameAttributeName("id")
-				.clientName("client-1")
-				.build();
+				.scope("read", "write");
 		// @formatter:on
 	}
 
@@ -114,7 +114,7 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		Instant expiresAtBefore = Instant.now().plusSeconds(3600);
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient
-				.getTokenResponse(this.authorizationCodeGrantRequest());
+				.getTokenResponse(authorizationCodeGrantRequest(this.clientRegistration.build()));
 		Instant expiresAtAfter = Instant.now().plusSeconds(3600);
 		RecordedRequest recordedRequest = this.server.takeRequest();
 		assertThat(recordedRequest.getMethod()).isEqualTo(HttpMethod.POST.toString());
@@ -136,7 +136,7 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 	}
 
 	@Test
-	public void getTokenResponseWhenClientAuthenticationBasicThenAuthorizationHeaderIsSent() throws Exception {
+	public void getTokenResponseWhenAuthenticationClientSecretBasicThenAuthorizationHeaderIsSent() throws Exception {
 		// @formatter:off
 		String accessTokenSuccessResponse = "{\n"
 			+ "   \"access_token\": \"access-token-1234\",\n"
@@ -145,13 +145,13 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 			+ "}\n";
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
-		this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest());
+		this.tokenResponseClient.getTokenResponse(authorizationCodeGrantRequest(this.clientRegistration.build()));
 		RecordedRequest recordedRequest = this.server.takeRequest();
 		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).startsWith("Basic ");
 	}
 
 	@Test
-	public void getTokenResponseWhenClientAuthenticationPostThenFormParametersAreSent() throws Exception {
+	public void getTokenResponseWhenAuthenticationClientSecretPostThenFormParametersAreSent() throws Exception {
 		// @formatter:off
 		String accessTokenSuccessResponse = "{\n"
 			+ "   \"access_token\": \"access-token-1234\",\n"
@@ -160,9 +160,9 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 			+ "}\n";
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
-		ClientRegistration clientRegistration = this.from(this.clientRegistration)
+		ClientRegistration clientRegistration = this.clientRegistration
 				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST).build();
-		this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest(clientRegistration));
+		this.tokenResponseClient.getTokenResponse(authorizationCodeGrantRequest(clientRegistration));
 		RecordedRequest recordedRequest = this.server.takeRequest();
 		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
 		String formParameters = recordedRequest.getBody().readUtf8();
@@ -170,6 +170,79 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 		assertThat(formParameters).contains("client_secret=secret");
 	}
 
+	@Test
+	public void getTokenResponseWhenAuthenticationClientSecretJwtThenFormParametersAreSent() throws Exception {
+		// @formatter:off
+		String accessTokenSuccessResponse = "{\n"
+				+ "   \"access_token\": \"access-token-1234\",\n"
+				+ "   \"token_type\": \"bearer\",\n"
+				+ "   \"expires_in\": \"3600\"\n"
+				+ "}\n";
+		// @formatter:on
+		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
+
+		// @formatter:off
+		ClientRegistration clientRegistration = this.clientRegistration
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.clientSecret(TestKeys.DEFAULT_ENCODED_SECRET_KEY)
+				.build();
+		// @formatter:on
+
+		// Configure Jwt client authentication converter
+		SecretKeySpec secretKey = new SecretKeySpec(
+				clientRegistration.getClientSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+		JWK jwk = TestJwks.jwk(secretKey).build();
+		Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
+		configureJwtClientAuthenticationConverter(jwkResolver);
+
+		this.tokenResponseClient.getTokenResponse(authorizationCodeGrantRequest(clientRegistration));
+		RecordedRequest recordedRequest = this.server.takeRequest();
+		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
+		String formParameters = recordedRequest.getBody().readUtf8();
+		assertThat(formParameters)
+				.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer");
+		assertThat(formParameters).contains("client_assertion=");
+	}
+
+	@Test
+	public void getTokenResponseWhenAuthenticationPrivateKeyJwtThenFormParametersAreSent() throws Exception {
+		// @formatter:off
+		String accessTokenSuccessResponse = "{\n"
+				+ "   \"access_token\": \"access-token-1234\",\n"
+				+ "   \"token_type\": \"bearer\",\n"
+				+ "   \"expires_in\": \"3600\"\n"
+				+ "}\n";
+		// @formatter:on
+		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
+
+		// @formatter:off
+		ClientRegistration clientRegistration = this.clientRegistration
+				.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
+				.build();
+		// @formatter:on
+
+		// Configure Jwt client authentication converter
+		JWK jwk = TestJwks.DEFAULT_RSA_JWK;
+		Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
+		configureJwtClientAuthenticationConverter(jwkResolver);
+
+		this.tokenResponseClient.getTokenResponse(authorizationCodeGrantRequest(clientRegistration));
+		RecordedRequest recordedRequest = this.server.takeRequest();
+		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
+		String formParameters = recordedRequest.getBody().readUtf8();
+		assertThat(formParameters)
+				.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer");
+		assertThat(formParameters).contains("client_assertion=");
+	}
+
+	private void configureJwtClientAuthenticationConverter(Function<ClientRegistration, JWK> jwkResolver) {
+		NimbusJwtClientAuthenticationParametersConverter<OAuth2AuthorizationCodeGrantRequest> jwtClientAuthenticationConverter = new NimbusJwtClientAuthenticationParametersConverter<>(
+				jwkResolver);
+		OAuth2AuthorizationCodeGrantRequestEntityConverter requestEntityConverter = new OAuth2AuthorizationCodeGrantRequestEntityConverter();
+		requestEntityConverter.addParametersConverter(jwtClientAuthenticationConverter);
+		this.tokenResponseClient.setRequestEntityConverter(requestEntityConverter);
+	}
+
 	@Test
 	public void getTokenResponseWhenSuccessResponseAndNotBearerTokenTypeThenThrowOAuth2AuthorizationException() {
 		// @formatter:off
@@ -181,7 +254,8 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
-				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest()))
+				.isThrownBy(() -> this.tokenResponseClient
+						.getTokenResponse(authorizationCodeGrantRequest(this.clientRegistration.build())))
 				.withMessageContaining(
 						"[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response")
 				.withMessageContaining("tokenType cannot be null");
@@ -196,7 +270,8 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
-				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest()))
+				.isThrownBy(() -> this.tokenResponseClient
+						.getTokenResponse(authorizationCodeGrantRequest(this.clientRegistration.build())))
 				.withMessageContaining(
 						"[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response")
 				.withMessageContaining("tokenType cannot be null");
@@ -215,7 +290,7 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient
-				.getTokenResponse(this.authorizationCodeGrantRequest());
+				.getTokenResponse(authorizationCodeGrantRequest(this.clientRegistration.build()));
 		assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read");
 	}
 
@@ -231,16 +306,16 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient
-				.getTokenResponse(this.authorizationCodeGrantRequest());
+				.getTokenResponse(authorizationCodeGrantRequest(this.clientRegistration.build()));
 		assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read", "write");
 	}
 
 	@Test
 	public void getTokenResponseWhenTokenUriInvalidThenThrowOAuth2AuthorizationException() {
 		String invalidTokenUri = "https://invalid-provider.com/oauth2/token";
-		ClientRegistration clientRegistration = this.from(this.clientRegistration).tokenUri(invalidTokenUri).build();
+		ClientRegistration clientRegistration = this.clientRegistration.tokenUri(invalidTokenUri).build();
 		assertThatExceptionOfType(OAuth2AuthorizationException.class).isThrownBy(
-				() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest(clientRegistration)))
+				() -> this.tokenResponseClient.getTokenResponse(authorizationCodeGrantRequest(clientRegistration)))
 				.withMessageContaining(
 						"[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response");
 	}
@@ -260,7 +335,8 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
-				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest()))
+				.isThrownBy(() -> this.tokenResponseClient
+						.getTokenResponse(authorizationCodeGrantRequest(this.clientRegistration.build())))
 				.withMessageContaining(
 						"[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response");
 	}
@@ -270,7 +346,8 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 		String accessTokenErrorResponse = "{\n" + "   \"error\": \"unauthorized_client\"\n" + "}\n";
 		this.server.enqueue(jsonResponse(accessTokenErrorResponse).setResponseCode(400));
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
-				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest()))
+				.isThrownBy(() -> this.tokenResponseClient
+						.getTokenResponse(authorizationCodeGrantRequest(this.clientRegistration.build())))
 				.withMessageContaining("[unauthorized_client]");
 	}
 
@@ -278,15 +355,12 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 	public void getTokenResponseWhenServerErrorResponseThenThrowOAuth2AuthorizationException() {
 		this.server.enqueue(new MockResponse().setResponseCode(500));
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
-				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(this.authorizationCodeGrantRequest()))
+				.isThrownBy(() -> this.tokenResponseClient
+						.getTokenResponse(authorizationCodeGrantRequest(this.clientRegistration.build())))
 				.withMessageContaining("[invalid_token_response] An error occurred while attempting to retrieve "
 						+ "the OAuth 2.0 Access Token Response");
 	}
 
-	private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest() {
-		return this.authorizationCodeGrantRequest(this.clientRegistration);
-	}
-
 	private OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest(ClientRegistration clientRegistration) {
 		OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
 				.clientId(clientRegistration.getClientId()).state("state-1234")
@@ -303,22 +377,4 @@ public class DefaultAuthorizationCodeTokenResponseClientTests {
 		return new MockResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody(json);
 	}
 
-	private ClientRegistration.Builder from(ClientRegistration registration) {
-		// @formatter:off
-		return ClientRegistration.withRegistrationId(registration.getRegistrationId())
-				.clientId(registration.getClientId())
-				.clientSecret(registration.getClientSecret())
-				.clientAuthenticationMethod(registration.getClientAuthenticationMethod())
-				.authorizationGrantType(registration.getAuthorizationGrantType())
-				.redirectUri(registration.getRedirectUri())
-				.scope(registration.getScopes())
-				.authorizationUri(registration.getProviderDetails().getAuthorizationUri())
-				.tokenUri(registration.getProviderDetails().getTokenUri())
-				.userInfoUri(registration.getProviderDetails().getUserInfoEndpoint().getUri())
-				.userNameAttributeName(
-						registration.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName())
-				.clientName(registration.getClientName());
-		// @formatter:on
-	}
-
 }

+ 104 - 34
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultClientCredentialsTokenResponseClientTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,8 +16,13 @@
 
 package org.springframework.security.oauth2.client.endpoint;
 
+import java.nio.charset.StandardCharsets;
 import java.time.Instant;
+import java.util.function.Function;
 
+import javax.crypto.spec.SecretKeySpec;
+
+import com.nimbusds.jose.jwk.JWK;
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import okhttp3.mockwebserver.RecordedRequest;
@@ -29,11 +34,13 @@ import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
-import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.TestKeys;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -46,26 +53,24 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
  */
 public class DefaultClientCredentialsTokenResponseClientTests {
 
-	private DefaultClientCredentialsTokenResponseClient tokenResponseClient = new DefaultClientCredentialsTokenResponseClient();
+	private DefaultClientCredentialsTokenResponseClient tokenResponseClient;
 
-	private ClientRegistration clientRegistration;
+	private ClientRegistration.Builder clientRegistration;
 
 	private MockWebServer server;
 
 	@Before
 	public void setup() throws Exception {
+		this.tokenResponseClient = new DefaultClientCredentialsTokenResponseClient();
 		this.server = new MockWebServer();
 		this.server.start();
 		String tokenUri = this.server.url("/oauth2/token").toString();
 		// @formatter:off
-		this.clientRegistration = ClientRegistration.withRegistrationId("registration-1")
+		this.clientRegistration = TestClientRegistrations.clientCredentials()
 				.clientId("client-1")
 				.clientSecret("secret")
-				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
-				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
-				.scope("read", "write")
 				.tokenUri(tokenUri)
-				.build();
+				.scope("read", "write");
 		// @formatter:on
 	}
 
@@ -110,7 +115,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		Instant expiresAtBefore = Instant.now().plusSeconds(3600);
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
-				this.clientRegistration);
+				this.clientRegistration.build());
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient
 				.getTokenResponse(clientCredentialsGrantRequest);
 		Instant expiresAtAfter = Instant.now().plusSeconds(3600);
@@ -133,7 +138,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 	}
 
 	@Test
-	public void getTokenResponseWhenClientAuthenticationBasicThenAuthorizationHeaderIsSent() throws Exception {
+	public void getTokenResponseWhenAuthenticationClientSecretBasicThenAuthorizationHeaderIsSent() throws Exception {
 		// @formatter:off
 		String accessTokenSuccessResponse = "{\n"
 			+ "   \"access_token\": \"access-token-1234\",\n"
@@ -143,14 +148,14 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
-				this.clientRegistration);
+				this.clientRegistration.build());
 		this.tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest);
 		RecordedRequest recordedRequest = this.server.takeRequest();
 		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).startsWith("Basic ");
 	}
 
 	@Test
-	public void getTokenResponseWhenClientAuthenticationPostThenFormParametersAreSent() throws Exception {
+	public void getTokenResponseWhenAuthenticationClientSecretPostThenFormParametersAreSent() throws Exception {
 		// @formatter:off
 		String accessTokenSuccessResponse = "{\n"
 			+ "	\"access_token\": \"access-token-1234\",\n"
@@ -159,7 +164,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 			+ "}\n";
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
-		ClientRegistration clientRegistration = this.from(this.clientRegistration)
+		ClientRegistration clientRegistration = this.clientRegistration
 				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST).build();
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
 				clientRegistration);
@@ -171,6 +176,83 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		assertThat(formParameters).contains("client_secret=secret");
 	}
 
+	@Test
+	public void getTokenResponseWhenAuthenticationClientSecretJwtThenFormParametersAreSent() throws Exception {
+		// @formatter:off
+		String accessTokenSuccessResponse = "{\n"
+				+ "	\"access_token\": \"access-token-1234\",\n"
+				+ "   \"token_type\": \"bearer\",\n"
+				+ "   \"expires_in\": \"3600\"\n"
+				+ "}\n";
+		// @formatter:on
+		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
+
+		// @formatter:off
+		ClientRegistration clientRegistration = this.clientRegistration
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.clientSecret(TestKeys.DEFAULT_ENCODED_SECRET_KEY)
+				.build();
+		// @formatter:on
+
+		// Configure Jwt client authentication converter
+		SecretKeySpec secretKey = new SecretKeySpec(
+				clientRegistration.getClientSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+		JWK jwk = TestJwks.jwk(secretKey).build();
+		Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
+		configureJwtClientAuthenticationConverter(jwkResolver);
+
+		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
+				clientRegistration);
+		this.tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest);
+		RecordedRequest recordedRequest = this.server.takeRequest();
+		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
+		String formParameters = recordedRequest.getBody().readUtf8();
+		assertThat(formParameters)
+				.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer");
+		assertThat(formParameters).contains("client_assertion=");
+	}
+
+	@Test
+	public void getTokenResponseWhenAuthenticationPrivateKeyJwtThenFormParametersAreSent() throws Exception {
+		// @formatter:off
+		String accessTokenSuccessResponse = "{\n"
+				+ "	\"access_token\": \"access-token-1234\",\n"
+				+ "   \"token_type\": \"bearer\",\n"
+				+ "   \"expires_in\": \"3600\"\n"
+				+ "}\n";
+		// @formatter:on
+		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
+
+		// @formatter:off
+		ClientRegistration clientRegistration = this.clientRegistration
+				.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
+				.build();
+		// @formatter:on
+
+		// Configure Jwt client authentication converter
+		JWK jwk = TestJwks.DEFAULT_RSA_JWK;
+		Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
+		configureJwtClientAuthenticationConverter(jwkResolver);
+
+		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
+				clientRegistration);
+		this.tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest);
+		RecordedRequest recordedRequest = this.server.takeRequest();
+		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
+		String formParameters = recordedRequest.getBody().readUtf8();
+		assertThat(formParameters)
+				.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer");
+		assertThat(formParameters).contains("client_assertion=");
+	}
+
+	private void configureJwtClientAuthenticationConverter(Function<ClientRegistration, JWK> jwkResolver) {
+		NimbusJwtClientAuthenticationParametersConverter<OAuth2ClientCredentialsGrantRequest> jwtClientAuthenticationConverter = new NimbusJwtClientAuthenticationParametersConverter<>(
+				jwkResolver);
+		OAuth2ClientCredentialsGrantRequestEntityConverter requestEntityConverter = new OAuth2ClientCredentialsGrantRequestEntityConverter();
+		requestEntityConverter.addParametersConverter(jwtClientAuthenticationConverter);
+		this.tokenResponseClient.setRequestEntityConverter(requestEntityConverter);
+	}
+
 	@Test
 	public void getTokenResponseWhenSuccessResponseAndNotBearerTokenTypeThenThrowOAuth2AuthorizationException() {
 		// @formatter:off
@@ -182,7 +264,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
-				this.clientRegistration);
+				this.clientRegistration.build());
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest))
 				.withMessageContaining(
@@ -195,7 +277,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		String accessTokenSuccessResponse = "{\n" + "	\"access_token\": \"access-token-1234\"\n" + "}\n";
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
-				this.clientRegistration);
+				this.clientRegistration.build());
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest))
 				.withMessageContaining(
@@ -215,7 +297,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
-				this.clientRegistration);
+				this.clientRegistration.build());
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient
 				.getTokenResponse(clientCredentialsGrantRequest);
 		assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read");
@@ -232,7 +314,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
-				this.clientRegistration);
+				this.clientRegistration.build());
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient
 				.getTokenResponse(clientCredentialsGrantRequest);
 		assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read", "write");
@@ -241,7 +323,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 	@Test
 	public void getTokenResponseWhenTokenUriInvalidThenThrowOAuth2AuthorizationException() {
 		String invalidTokenUri = "https://invalid-provider.com/oauth2/token";
-		ClientRegistration clientRegistration = this.from(this.clientRegistration).tokenUri(invalidTokenUri).build();
+		ClientRegistration clientRegistration = this.clientRegistration.tokenUri(invalidTokenUri).build();
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
 				clientRegistration);
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
@@ -264,7 +346,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
-				this.clientRegistration);
+				this.clientRegistration.build());
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest))
 				.withMessageContaining(
@@ -280,7 +362,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenErrorResponse).setResponseCode(400));
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
-				this.clientRegistration);
+				this.clientRegistration.build());
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest))
 				.withMessageContaining("[unauthorized_client]");
@@ -290,7 +372,7 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 	public void getTokenResponseWhenServerErrorResponseThenThrowOAuth2AuthorizationException() {
 		this.server.enqueue(new MockResponse().setResponseCode(500));
 		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
-				this.clientRegistration);
+				this.clientRegistration.build());
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest))
 				.withMessageContaining(
@@ -301,16 +383,4 @@ public class DefaultClientCredentialsTokenResponseClientTests {
 		return new MockResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody(json);
 	}
 
-	private ClientRegistration.Builder from(ClientRegistration registration) {
-		// @formatter:off
-		return ClientRegistration.withRegistrationId(registration.getRegistrationId())
-				.clientId(registration.getClientId())
-				.clientSecret(registration.getClientSecret())
-				.clientAuthenticationMethod(registration.getClientAuthenticationMethod())
-				.authorizationGrantType(registration.getAuthorizationGrantType())
-				.scope(registration.getScopes())
-				.tokenUri(registration.getProviderDetails().getTokenUri());
-		// @formatter:on
-	}
-
 }

+ 100 - 13
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultPasswordTokenResponseClientTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,8 +16,13 @@
 
 package org.springframework.security.oauth2.client.endpoint;
 
+import java.nio.charset.StandardCharsets;
 import java.time.Instant;
+import java.util.function.Function;
 
+import javax.crypto.spec.SecretKeySpec;
+
+import com.nimbusds.jose.jwk.JWK;
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import okhttp3.mockwebserver.RecordedRequest;
@@ -30,11 +35,12 @@ import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
-import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.TestKeys;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -47,9 +53,9 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
  */
 public class DefaultPasswordTokenResponseClientTests {
 
-	private DefaultPasswordTokenResponseClient tokenResponseClient = new DefaultPasswordTokenResponseClient();
+	private DefaultPasswordTokenResponseClient tokenResponseClient;
 
-	private ClientRegistration.Builder clientRegistrationBuilder;
+	private ClientRegistration.Builder clientRegistration;
 
 	private String username = "user1";
 
@@ -59,11 +65,15 @@ public class DefaultPasswordTokenResponseClientTests {
 
 	@Before
 	public void setup() throws Exception {
+		this.tokenResponseClient = new DefaultPasswordTokenResponseClient();
 		this.server = new MockWebServer();
 		this.server.start();
 		String tokenUri = this.server.url("/oauth2/token").toString();
-		this.clientRegistrationBuilder = TestClientRegistrations.clientRegistration()
-				.authorizationGrantType(AuthorizationGrantType.PASSWORD).scope("read", "write").tokenUri(tokenUri);
+		// @formatter:off
+		this.clientRegistration = TestClientRegistrations.password()
+				.scope("read", "write")
+				.tokenUri(tokenUri);
+		// @formatter:on
 	}
 
 	@After
@@ -97,7 +107,7 @@ public class DefaultPasswordTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		Instant expiresAtBefore = Instant.now().plusSeconds(3600);
-		ClientRegistration clientRegistration = this.clientRegistrationBuilder.build();
+		ClientRegistration clientRegistration = this.clientRegistration.build();
 		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration,
 				this.username, this.password);
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient.getTokenResponse(passwordGrantRequest);
@@ -121,7 +131,7 @@ public class DefaultPasswordTokenResponseClientTests {
 	}
 
 	@Test
-	public void getTokenResponseWhenClientAuthenticationPostThenFormParametersAreSent() throws Exception {
+	public void getTokenResponseWhenAuthenticationClientSecretPostThenFormParametersAreSent() throws Exception {
 		// @formatter:off
 		String accessTokenSuccessResponse = "{\n"
 			+ "   \"access_token\": \"access-token-1234\",\n"
@@ -130,7 +140,7 @@ public class DefaultPasswordTokenResponseClientTests {
 			+ "}\n";
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
-		ClientRegistration clientRegistration = this.clientRegistrationBuilder
+		ClientRegistration clientRegistration = this.clientRegistration
 				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST).build();
 		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration,
 				this.username, this.password);
@@ -142,6 +152,83 @@ public class DefaultPasswordTokenResponseClientTests {
 		assertThat(formParameters).contains("client_secret=client-secret");
 	}
 
+	@Test
+	public void getTokenResponseWhenAuthenticationClientSecretJwtThenFormParametersAreSent() throws Exception {
+		// @formatter:off
+		String accessTokenSuccessResponse = "{\n"
+				+ "	\"access_token\": \"access-token-1234\",\n"
+				+ "   \"token_type\": \"bearer\",\n"
+				+ "   \"expires_in\": \"3600\"\n"
+				+ "}\n";
+		// @formatter:on
+		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
+
+		// @formatter:off
+		ClientRegistration clientRegistration = this.clientRegistration
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.clientSecret(TestKeys.DEFAULT_ENCODED_SECRET_KEY)
+				.build();
+		// @formatter:on
+
+		// Configure Jwt client authentication converter
+		SecretKeySpec secretKey = new SecretKeySpec(
+				clientRegistration.getClientSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+		JWK jwk = TestJwks.jwk(secretKey).build();
+		Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
+		configureJwtClientAuthenticationConverter(jwkResolver);
+
+		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration,
+				this.username, this.password);
+		this.tokenResponseClient.getTokenResponse(passwordGrantRequest);
+		RecordedRequest recordedRequest = this.server.takeRequest();
+		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
+		String formParameters = recordedRequest.getBody().readUtf8();
+		assertThat(formParameters)
+				.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer");
+		assertThat(formParameters).contains("client_assertion=");
+	}
+
+	@Test
+	public void getTokenResponseWhenAuthenticationPrivateKeyJwtThenFormParametersAreSent() throws Exception {
+		// @formatter:off
+		String accessTokenSuccessResponse = "{\n"
+				+ "	\"access_token\": \"access-token-1234\",\n"
+				+ "   \"token_type\": \"bearer\",\n"
+				+ "   \"expires_in\": \"3600\"\n"
+				+ "}\n";
+		// @formatter:on
+		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
+
+		// @formatter:off
+		ClientRegistration clientRegistration = this.clientRegistration
+				.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
+				.build();
+		// @formatter:on
+
+		// Configure Jwt client authentication converter
+		JWK jwk = TestJwks.DEFAULT_RSA_JWK;
+		Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
+		configureJwtClientAuthenticationConverter(jwkResolver);
+
+		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration,
+				this.username, this.password);
+		this.tokenResponseClient.getTokenResponse(passwordGrantRequest);
+		RecordedRequest recordedRequest = this.server.takeRequest();
+		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
+		String formParameters = recordedRequest.getBody().readUtf8();
+		assertThat(formParameters)
+				.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer");
+		assertThat(formParameters).contains("client_assertion=");
+	}
+
+	private void configureJwtClientAuthenticationConverter(Function<ClientRegistration, JWK> jwkResolver) {
+		NimbusJwtClientAuthenticationParametersConverter<OAuth2PasswordGrantRequest> jwtClientAuthenticationConverter = new NimbusJwtClientAuthenticationParametersConverter<>(
+				jwkResolver);
+		OAuth2PasswordGrantRequestEntityConverter requestEntityConverter = new OAuth2PasswordGrantRequestEntityConverter();
+		requestEntityConverter.addParametersConverter(jwtClientAuthenticationConverter);
+		this.tokenResponseClient.setRequestEntityConverter(requestEntityConverter);
+	}
+
 	@Test
 	public void getTokenResponseWhenSuccessResponseAndNotBearerTokenTypeThenThrowOAuth2AuthorizationException() {
 		// @formatter:off
@@ -153,7 +240,7 @@ public class DefaultPasswordTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(
-				this.clientRegistrationBuilder.build(), this.username, this.password);
+				this.clientRegistration.build(), this.username, this.password);
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(passwordGrantRequest))
 				.withMessageContaining(
@@ -173,7 +260,7 @@ public class DefaultPasswordTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(
-				this.clientRegistrationBuilder.build(), this.username, this.password);
+				this.clientRegistration.build(), this.username, this.password);
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient.getTokenResponse(passwordGrantRequest);
 		RecordedRequest recordedRequest = this.server.takeRequest();
 		String formParameters = recordedRequest.getBody().readUtf8();
@@ -186,7 +273,7 @@ public class DefaultPasswordTokenResponseClientTests {
 		String accessTokenErrorResponse = "{\n" + "   \"error\": \"unauthorized_client\"\n" + "}\n";
 		this.server.enqueue(jsonResponse(accessTokenErrorResponse).setResponseCode(400));
 		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(
-				this.clientRegistrationBuilder.build(), this.username, this.password);
+				this.clientRegistration.build(), this.username, this.password);
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(passwordGrantRequest))
 				.withMessageContaining("[unauthorized_client]");
@@ -196,7 +283,7 @@ public class DefaultPasswordTokenResponseClientTests {
 	public void getTokenResponseWhenServerErrorResponseThenThrowOAuth2AuthorizationException() {
 		this.server.enqueue(new MockResponse().setResponseCode(500));
 		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(
-				this.clientRegistrationBuilder.build(), this.username, this.password);
+				this.clientRegistration.build(), this.username, this.password);
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(passwordGrantRequest))
 				.withMessageContaining("[invalid_token_response] An error occurred while attempting to "

+ 103 - 14
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultRefreshTokenTokenResponseClientTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,9 +16,14 @@
 
 package org.springframework.security.oauth2.client.endpoint;
 
+import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.util.Collections;
+import java.util.function.Function;
 
+import javax.crypto.spec.SecretKeySpec;
+
+import com.nimbusds.jose.jwk.JWK;
 import okhttp3.mockwebserver.MockResponse;
 import okhttp3.mockwebserver.MockWebServer;
 import okhttp3.mockwebserver.RecordedRequest;
@@ -38,6 +43,8 @@ import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.TestOAuth2AccessTokens;
 import org.springframework.security.oauth2.core.TestOAuth2RefreshTokens;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.TestKeys;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -50,9 +57,9 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
  */
 public class DefaultRefreshTokenTokenResponseClientTests {
 
-	private DefaultRefreshTokenTokenResponseClient tokenResponseClient = new DefaultRefreshTokenTokenResponseClient();
+	private DefaultRefreshTokenTokenResponseClient tokenResponseClient;
 
-	private ClientRegistration.Builder clientRegistrationBuilder;
+	private ClientRegistration.Builder clientRegistration;
 
 	private OAuth2AccessToken accessToken;
 
@@ -62,10 +69,11 @@ public class DefaultRefreshTokenTokenResponseClientTests {
 
 	@Before
 	public void setup() throws Exception {
+		this.tokenResponseClient = new DefaultRefreshTokenTokenResponseClient();
 		this.server = new MockWebServer();
 		this.server.start();
 		String tokenUri = this.server.url("/oauth2/token").toString();
-		this.clientRegistrationBuilder = TestClientRegistrations.clientRegistration().tokenUri(tokenUri);
+		this.clientRegistration = TestClientRegistrations.clientRegistration().tokenUri(tokenUri);
 		this.accessToken = TestOAuth2AccessTokens.scopes("read", "write");
 		this.refreshToken = TestOAuth2RefreshTokens.refreshToken();
 	}
@@ -102,7 +110,7 @@ public class DefaultRefreshTokenTokenResponseClientTests {
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		Instant expiresAtBefore = Instant.now().plusSeconds(3600);
 		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(
-				this.clientRegistrationBuilder.build(), this.accessToken, this.refreshToken);
+				this.clientRegistration.build(), this.accessToken, this.refreshToken);
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient
 				.getTokenResponse(refreshTokenGrantRequest);
 		Instant expiresAtAfter = Instant.now().plusSeconds(3600);
@@ -124,11 +132,16 @@ public class DefaultRefreshTokenTokenResponseClientTests {
 	}
 
 	@Test
-	public void getTokenResponseWhenClientAuthenticationPostThenFormParametersAreSent() throws Exception {
-		String accessTokenSuccessResponse = "{\n" + "	\"access_token\": \"access-token-1234\",\n"
-				+ "   \"token_type\": \"bearer\",\n" + "   \"expires_in\": \"3600\"\n" + "}\n";
+	public void getTokenResponseWhenAuthenticationClientSecretPostThenFormParametersAreSent() throws Exception {
+		// @formatter:off
+		String accessTokenSuccessResponse = "{\n"
+				+ "   \"access_token\": \"access-token-1234\",\n"
+				+ "   \"token_type\": \"bearer\",\n"
+				+ "   \"expires_in\": \"3600\"\n"
+				+ "}\n";
+		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
-		ClientRegistration clientRegistration = this.clientRegistrationBuilder
+		ClientRegistration clientRegistration = this.clientRegistration
 				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST).build();
 		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(clientRegistration,
 				this.accessToken, this.refreshToken);
@@ -140,6 +153,83 @@ public class DefaultRefreshTokenTokenResponseClientTests {
 		assertThat(formParameters).contains("client_secret=client-secret");
 	}
 
+	@Test
+	public void getTokenResponseWhenAuthenticationClientSecretJwtThenFormParametersAreSent() throws Exception {
+		// @formatter:off
+		String accessTokenSuccessResponse = "{\n"
+				+ "	\"access_token\": \"access-token-1234\",\n"
+				+ "   \"token_type\": \"bearer\",\n"
+				+ "   \"expires_in\": \"3600\"\n"
+				+ "}\n";
+		// @formatter:on
+		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
+
+		// @formatter:off
+		ClientRegistration clientRegistration = this.clientRegistration
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.clientSecret(TestKeys.DEFAULT_ENCODED_SECRET_KEY)
+				.build();
+		// @formatter:on
+
+		// Configure Jwt client authentication converter
+		SecretKeySpec secretKey = new SecretKeySpec(
+				clientRegistration.getClientSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+		JWK jwk = TestJwks.jwk(secretKey).build();
+		Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
+		configureJwtClientAuthenticationConverter(jwkResolver);
+
+		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(clientRegistration,
+				this.accessToken, this.refreshToken);
+		this.tokenResponseClient.getTokenResponse(refreshTokenGrantRequest);
+		RecordedRequest recordedRequest = this.server.takeRequest();
+		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
+		String formParameters = recordedRequest.getBody().readUtf8();
+		assertThat(formParameters)
+				.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer");
+		assertThat(formParameters).contains("client_assertion=");
+	}
+
+	@Test
+	public void getTokenResponseWhenAuthenticationPrivateKeyJwtThenFormParametersAreSent() throws Exception {
+		// @formatter:off
+		String accessTokenSuccessResponse = "{\n"
+				+ "	\"access_token\": \"access-token-1234\",\n"
+				+ "   \"token_type\": \"bearer\",\n"
+				+ "   \"expires_in\": \"3600\"\n"
+				+ "}\n";
+		// @formatter:on
+		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
+
+		// @formatter:off
+		ClientRegistration clientRegistration = this.clientRegistration
+				.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
+				.build();
+		// @formatter:on
+
+		// Configure Jwt client authentication converter
+		JWK jwk = TestJwks.DEFAULT_RSA_JWK;
+		Function<ClientRegistration, JWK> jwkResolver = (registration) -> jwk;
+		configureJwtClientAuthenticationConverter(jwkResolver);
+
+		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(clientRegistration,
+				this.accessToken, this.refreshToken);
+		this.tokenResponseClient.getTokenResponse(refreshTokenGrantRequest);
+		RecordedRequest recordedRequest = this.server.takeRequest();
+		assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
+		String formParameters = recordedRequest.getBody().readUtf8();
+		assertThat(formParameters)
+				.contains("client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer");
+		assertThat(formParameters).contains("client_assertion=");
+	}
+
+	private void configureJwtClientAuthenticationConverter(Function<ClientRegistration, JWK> jwkResolver) {
+		NimbusJwtClientAuthenticationParametersConverter<OAuth2RefreshTokenGrantRequest> jwtClientAuthenticationConverter = new NimbusJwtClientAuthenticationParametersConverter<>(
+				jwkResolver);
+		OAuth2RefreshTokenGrantRequestEntityConverter requestEntityConverter = new OAuth2RefreshTokenGrantRequestEntityConverter();
+		requestEntityConverter.addParametersConverter(jwtClientAuthenticationConverter);
+		this.tokenResponseClient.setRequestEntityConverter(requestEntityConverter);
+	}
+
 	@Test
 	public void getTokenResponseWhenSuccessResponseAndNotBearerTokenTypeThenThrowOAuth2AuthorizationException() {
 		// @formatter:off
@@ -151,7 +241,7 @@ public class DefaultRefreshTokenTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(
-				this.clientRegistrationBuilder.build(), this.accessToken, this.refreshToken);
+				this.clientRegistration.build(), this.accessToken, this.refreshToken);
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(refreshTokenGrantRequest))
 				.withMessageContaining("[invalid_token_response] An error occurred while attempting to "
@@ -171,8 +261,7 @@ public class DefaultRefreshTokenTokenResponseClientTests {
 		// @formatter:on
 		this.server.enqueue(jsonResponse(accessTokenSuccessResponse));
 		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(
-				this.clientRegistrationBuilder.build(), this.accessToken, this.refreshToken,
-				Collections.singleton("read"));
+				this.clientRegistration.build(), this.accessToken, this.refreshToken, Collections.singleton("read"));
 		OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient
 				.getTokenResponse(refreshTokenGrantRequest);
 		RecordedRequest recordedRequest = this.server.takeRequest();
@@ -186,7 +275,7 @@ public class DefaultRefreshTokenTokenResponseClientTests {
 		String accessTokenErrorResponse = "{\n" + "   \"error\": \"unauthorized_client\"\n" + "}\n";
 		this.server.enqueue(jsonResponse(accessTokenErrorResponse).setResponseCode(400));
 		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(
-				this.clientRegistrationBuilder.build(), this.accessToken, this.refreshToken);
+				this.clientRegistration.build(), this.accessToken, this.refreshToken);
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(refreshTokenGrantRequest))
 				.withMessageContaining("[unauthorized_client]");
@@ -196,7 +285,7 @@ public class DefaultRefreshTokenTokenResponseClientTests {
 	public void getTokenResponseWhenServerErrorResponseThenThrowOAuth2AuthorizationException() {
 		this.server.enqueue(new MockResponse().setResponseCode(500));
 		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(
-				this.clientRegistrationBuilder.build(), this.accessToken, this.refreshToken);
+				this.clientRegistration.build(), this.accessToken, this.refreshToken);
 		assertThatExceptionOfType(OAuth2AuthorizationException.class)
 				.isThrownBy(() -> this.tokenResponseClient.getTokenResponse(refreshTokenGrantRequest))
 				.withMessageContaining("[invalid_token_response] An error occurred while attempting to "

+ 123 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JoseHeaderTests.java

@@ -0,0 +1,123 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import org.junit.Test;
+
+import org.springframework.security.oauth2.jose.JwaAlgorithm;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * Tests for {@link JoseHeader}.
+ *
+ * @author Joe Grandja
+ */
+public class JoseHeaderTests {
+
+	@Test
+	public void withAlgorithmWhenNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JoseHeader.withAlgorithm(null))
+				.isInstanceOf(IllegalArgumentException.class).withMessage("jwaAlgorithm cannot be null");
+	}
+
+	@Test
+	public void buildWhenAllHeadersProvidedThenAllHeadersAreSet() {
+		JoseHeader expectedJoseHeader = TestJoseHeaders.joseHeader().build();
+
+		// @formatter:off
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(expectedJoseHeader.getAlgorithm())
+				.jwkSetUrl(expectedJoseHeader.getJwkSetUrl().toExternalForm())
+				.jwk(expectedJoseHeader.getJwk())
+				.keyId(expectedJoseHeader.getKeyId())
+				.x509Url(expectedJoseHeader.getX509Url().toExternalForm())
+				.x509CertificateChain(expectedJoseHeader.getX509CertificateChain())
+				.x509SHA1Thumbprint(expectedJoseHeader.getX509SHA1Thumbprint())
+				.x509SHA256Thumbprint(expectedJoseHeader.getX509SHA256Thumbprint())
+				.type(expectedJoseHeader.getType())
+				.contentType(expectedJoseHeader.getContentType())
+				.headers((headers) -> headers.put("custom-header-name", "custom-header-value"))
+				.build();
+		// @formatter:on
+
+		assertThat(joseHeader.<JwaAlgorithm>getAlgorithm()).isEqualTo(expectedJoseHeader.getAlgorithm());
+		assertThat(joseHeader.getJwkSetUrl()).isEqualTo(expectedJoseHeader.getJwkSetUrl());
+		assertThat(joseHeader.getJwk()).isEqualTo(expectedJoseHeader.getJwk());
+		assertThat(joseHeader.getKeyId()).isEqualTo(expectedJoseHeader.getKeyId());
+		assertThat(joseHeader.getX509Url()).isEqualTo(expectedJoseHeader.getX509Url());
+		assertThat(joseHeader.getX509CertificateChain()).isEqualTo(expectedJoseHeader.getX509CertificateChain());
+		assertThat(joseHeader.getX509SHA1Thumbprint()).isEqualTo(expectedJoseHeader.getX509SHA1Thumbprint());
+		assertThat(joseHeader.getX509SHA256Thumbprint()).isEqualTo(expectedJoseHeader.getX509SHA256Thumbprint());
+		assertThat(joseHeader.getType()).isEqualTo(expectedJoseHeader.getType());
+		assertThat(joseHeader.getContentType()).isEqualTo(expectedJoseHeader.getContentType());
+		assertThat(joseHeader.<String>getHeader("custom-header-name")).isEqualTo("custom-header-value");
+		assertThat(joseHeader.getHeaders()).isEqualTo(expectedJoseHeader.getHeaders());
+	}
+
+	@Test
+	public void fromWhenNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JoseHeader.from(null))
+				.isInstanceOf(IllegalArgumentException.class).withMessage("headers cannot be null");
+	}
+
+	@Test
+	public void fromWhenHeadersProvidedThenCopied() {
+		JoseHeader expectedJoseHeader = TestJoseHeaders.joseHeader().build();
+		JoseHeader joseHeader = JoseHeader.from(expectedJoseHeader).build();
+		assertThat(joseHeader.getHeaders()).isEqualTo(expectedJoseHeader.getHeaders());
+	}
+
+	@Test
+	public void headerWhenNameNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).header(null, "value"))
+				.withMessage("name cannot be empty");
+	}
+
+	@Test
+	public void headerWhenValueNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).header("name", null))
+				.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void getHeaderWhenNullThenThrowIllegalArgumentException() {
+		JoseHeader joseHeader = TestJoseHeaders.joseHeader().build();
+
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> joseHeader.getHeader(null))
+				.isInstanceOf(IllegalArgumentException.class).withMessage("name cannot be empty");
+	}
+
+}

+ 105 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSetTests.java

@@ -0,0 +1,105 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * Tests for {@link JwtClaimsSet}.
+ *
+ * @author Joe Grandja
+ */
+public class JwtClaimsSetTests {
+
+	@Test
+	public void buildWhenClaimsEmptyThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JwtClaimsSet.builder().build())
+				.isInstanceOf(IllegalArgumentException.class).withMessage("claims cannot be empty");
+	}
+
+	@Test
+	public void buildWhenAllClaimsProvidedThenAllClaimsAreSet() {
+		JwtClaimsSet expectedJwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		// @formatter:off
+		JwtClaimsSet jwtClaimsSet = JwtClaimsSet.builder()
+				.issuer(expectedJwtClaimsSet.getIssuer().toExternalForm())
+				.subject(expectedJwtClaimsSet.getSubject())
+				.audience(expectedJwtClaimsSet.getAudience())
+				.issuedAt(expectedJwtClaimsSet.getIssuedAt())
+				.notBefore(expectedJwtClaimsSet.getNotBefore())
+				.expiresAt(expectedJwtClaimsSet.getExpiresAt())
+				.id(expectedJwtClaimsSet.getId())
+				.claims((claims) -> claims.put("custom-claim-name", "custom-claim-value"))
+				.build();
+		// @formatter:on
+
+		assertThat(jwtClaimsSet.getIssuer()).isEqualTo(expectedJwtClaimsSet.getIssuer());
+		assertThat(jwtClaimsSet.getSubject()).isEqualTo(expectedJwtClaimsSet.getSubject());
+		assertThat(jwtClaimsSet.getAudience()).isEqualTo(expectedJwtClaimsSet.getAudience());
+		assertThat(jwtClaimsSet.getIssuedAt()).isEqualTo(expectedJwtClaimsSet.getIssuedAt());
+		assertThat(jwtClaimsSet.getNotBefore()).isEqualTo(expectedJwtClaimsSet.getNotBefore());
+		assertThat(jwtClaimsSet.getExpiresAt()).isEqualTo(expectedJwtClaimsSet.getExpiresAt());
+		assertThat(jwtClaimsSet.getId()).isEqualTo(expectedJwtClaimsSet.getId());
+		assertThat(jwtClaimsSet.<String>getClaim("custom-claim-name")).isEqualTo("custom-claim-value");
+		assertThat(jwtClaimsSet.getClaims()).isEqualTo(expectedJwtClaimsSet.getClaims());
+	}
+
+	@Test
+	public void fromWhenNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JwtClaimsSet.from(null))
+				.isInstanceOf(IllegalArgumentException.class).withMessage("claims cannot be null");
+	}
+
+	@Test
+	public void fromWhenClaimsProvidedThenCopied() {
+		JwtClaimsSet expectedJwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+		JwtClaimsSet jwtClaimsSet = JwtClaimsSet.from(expectedJwtClaimsSet).build();
+		assertThat(jwtClaimsSet.getClaims()).isEqualTo(expectedJwtClaimsSet.getClaims());
+	}
+
+	@Test
+	public void claimWhenNameNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> JwtClaimsSet.builder().claim(null, "value")).withMessage("name cannot be empty");
+	}
+
+	@Test
+	public void claimWhenValueNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> JwtClaimsSet.builder().claim("name", null)).withMessage("value cannot be null");
+	}
+
+}

+ 347 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoderTests.java

@@ -0,0 +1,347 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import com.nimbusds.jose.KeySourceException;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSelector;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.OctetSequenceKey;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jose.util.Base64URL;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+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.Jwt;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.willAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * Tests for {@link NimbusJwsEncoder}.
+ *
+ * @author Joe Grandja
+ */
+public class NimbusJwsEncoderTests {
+
+	private List<JWK> jwkList;
+
+	private JWKSource<SecurityContext> jwkSource;
+
+	private NimbusJwsEncoder jwsEncoder;
+
+	@Before
+	public void setUp() {
+		this.jwkList = new ArrayList<>();
+		this.jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(new JWKSet(this.jwkList));
+		this.jwsEncoder = new NimbusJwsEncoder(this.jwkSource);
+	}
+
+	@Test
+	public void constructorWhenJwkSourceNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new NimbusJwsEncoder(null))
+				.withMessage("jwkSource cannot be null");
+	}
+
+	@Test
+	public void encodeWhenHeadersNullThenThrowIllegalArgumentException() {
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		assertThatIllegalArgumentException().isThrownBy(() -> this.jwsEncoder.encode(null, jwtClaimsSet))
+				.withMessage("headers cannot be null");
+	}
+
+	@Test
+	public void encodeWhenClaimsNullThenThrowIllegalArgumentException() {
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+
+		assertThatIllegalArgumentException().isThrownBy(() -> this.jwsEncoder.encode(joseHeader, null))
+				.withMessage("claims cannot be null");
+	}
+
+	@Test
+	public void encodeWhenJwkSelectFailedThenThrowJwtEncodingException() throws Exception {
+		this.jwkSource = mock(JWKSource.class);
+		this.jwsEncoder = new NimbusJwsEncoder(this.jwkSource);
+		given(this.jwkSource.get(any(), any())).willThrow(new KeySourceException("key source error"));
+
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		assertThatExceptionOfType(JwtEncodingException.class)
+				.isThrownBy(() -> this.jwsEncoder.encode(joseHeader, jwtClaimsSet))
+				.withMessageContaining("Failed to select a JWK signing key -> key source error");
+	}
+
+	@Test
+	public void encodeWhenJwkMultipleSelectedThenThrowJwtEncodingException() throws Exception {
+		RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK;
+		this.jwkList.add(rsaJwk);
+		this.jwkList.add(rsaJwk);
+
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		assertThatExceptionOfType(JwtEncodingException.class)
+				.isThrownBy(() -> this.jwsEncoder.encode(joseHeader, jwtClaimsSet))
+				.withMessageContaining("Found multiple JWK signing keys for algorithm 'RS256'");
+	}
+
+	@Test
+	public void encodeWhenJwkSelectEmptyThenThrowJwtEncodingException() {
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		assertThatExceptionOfType(JwtEncodingException.class)
+				.isThrownBy(() -> this.jwsEncoder.encode(joseHeader, jwtClaimsSet))
+				.withMessageContaining("Failed to select a JWK signing key");
+	}
+
+	@Test
+	public void encodeWhenJwkSelectWithProvidedKidThenSelected() {
+		// @formatter:off
+		RSAKey rsaJwk1 = TestJwks.jwk(TestKeys.DEFAULT_PUBLIC_KEY, TestKeys.DEFAULT_PRIVATE_KEY)
+				.keyID("rsa-jwk-1")
+				.build();
+		this.jwkList.add(rsaJwk1);
+		RSAKey rsaJwk2 = TestJwks.jwk(TestKeys.DEFAULT_PUBLIC_KEY, TestKeys.DEFAULT_PRIVATE_KEY)
+				.keyID("rsa-jwk-2")
+				.build();
+		this.jwkList.add(rsaJwk2);
+		// @formatter:on
+
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).keyId(rsaJwk2.getKeyID()).build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		Jwt encodedJws = this.jwsEncoder.encode(joseHeader, jwtClaimsSet);
+
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(rsaJwk2.getKeyID());
+	}
+
+	@Test
+	public void encodeWhenJwkSelectWithProvidedX5TS256ThenSelected() {
+		// @formatter:off
+		RSAKey rsaJwk1 = TestJwks.jwk(TestKeys.DEFAULT_PUBLIC_KEY, TestKeys.DEFAULT_PRIVATE_KEY)
+				.x509CertSHA256Thumbprint(new Base64URL("x509CertSHA256Thumbprint-1"))
+				.keyID(null)
+				.build();
+		this.jwkList.add(rsaJwk1);
+		RSAKey rsaJwk2 = TestJwks.jwk(TestKeys.DEFAULT_PUBLIC_KEY, TestKeys.DEFAULT_PRIVATE_KEY)
+				.x509CertSHA256Thumbprint(new Base64URL("x509CertSHA256Thumbprint-2"))
+				.keyID(null)
+				.build();
+		this.jwkList.add(rsaJwk2);
+		// @formatter:on
+
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256)
+				.x509SHA256Thumbprint(rsaJwk1.getX509CertSHA256Thumbprint().toString()).build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		Jwt encodedJws = this.jwsEncoder.encode(joseHeader, jwtClaimsSet);
+
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.X5T_S256))
+				.isEqualTo(rsaJwk1.getX509CertSHA256Thumbprint().toString());
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.KID)).isNull();
+	}
+
+	@Test
+	public void encodeWhenJwkUseEncryptionThenThrowJwtEncodingException() throws Exception {
+		// @formatter:off
+		RSAKey rsaJwk = TestJwks.jwk(TestKeys.DEFAULT_PUBLIC_KEY, TestKeys.DEFAULT_PRIVATE_KEY)
+				.keyUse(KeyUse.ENCRYPTION)
+				.build();
+		// @formatter:on
+
+		this.jwkSource = mock(JWKSource.class);
+		this.jwsEncoder = new NimbusJwsEncoder(this.jwkSource);
+		given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk));
+
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		assertThatExceptionOfType(JwtEncodingException.class)
+				.isThrownBy(() -> this.jwsEncoder.encode(joseHeader, jwtClaimsSet)).withMessageContaining(
+						"Failed to create a JWS Signer -> The JWK use must be sig (signature) or unspecified");
+	}
+
+	@Test
+	public void encodeWhenSuccessThenDecodes() throws Exception {
+		// @formatter:off
+		RSAKey rsaJwk = TestJwks.jwk(TestKeys.DEFAULT_PUBLIC_KEY, TestKeys.DEFAULT_PRIVATE_KEY)
+				.keyID("rsa-jwk-1")
+				.x509CertSHA256Thumbprint(new Base64URL("x509CertSHA256Thumbprint-1"))
+				.build();
+		this.jwkList.add(rsaJwk);
+		// @formatter:on
+
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		Jwt encodedJws = this.jwsEncoder.encode(joseHeader, jwtClaimsSet);
+
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.ALG)).isEqualTo(joseHeader.getAlgorithm());
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.JKU)).isNull();
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.JWK)).isNull();
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(rsaJwk.getKeyID());
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.X5U)).isNull();
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.X5C)).isNull();
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.X5T)).isNull();
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.X5T_S256))
+				.isEqualTo(rsaJwk.getX509CertSHA256Thumbprint().toString());
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.TYP)).isNull();
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.CTY)).isNull();
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.CRIT)).isNull();
+
+		assertThat(encodedJws.getIssuer()).isEqualTo(jwtClaimsSet.getIssuer());
+		assertThat(encodedJws.getSubject()).isEqualTo(jwtClaimsSet.getSubject());
+		assertThat(encodedJws.getAudience()).isEqualTo(jwtClaimsSet.getAudience());
+		assertThat(encodedJws.getExpiresAt()).isEqualTo(jwtClaimsSet.getExpiresAt());
+		assertThat(encodedJws.getNotBefore()).isEqualTo(jwtClaimsSet.getNotBefore());
+		assertThat(encodedJws.getIssuedAt()).isEqualTo(jwtClaimsSet.getIssuedAt());
+		assertThat(encodedJws.getId()).isEqualTo(jwtClaimsSet.getId());
+		assertThat(encodedJws.<String>getClaim("custom-claim-name")).isEqualTo("custom-claim-value");
+
+		NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(rsaJwk.toRSAPublicKey()).build();
+		jwtDecoder.decode(encodedJws.getTokenValue());
+	}
+
+	@Test
+	public void encodeWhenKeysRotatedThenNewKeyUsed() throws Exception {
+		TestJWKSource jwkSource = new TestJWKSource();
+		JWKSource<SecurityContext> jwkSourceDelegate = spy(new JWKSource<SecurityContext>() {
+			@Override
+			public List<JWK> get(JWKSelector jwkSelector, SecurityContext context) {
+				return jwkSource.get(jwkSelector, context);
+			}
+		});
+		NimbusJwsEncoder jwsEncoder = new NimbusJwsEncoder(jwkSourceDelegate);
+
+		JwkListResultCaptor jwkListResultCaptor = new JwkListResultCaptor();
+		willAnswer(jwkListResultCaptor).given(jwkSourceDelegate).get(any(), any());
+
+		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		Jwt encodedJws = jwsEncoder.encode(joseHeader, jwtClaimsSet);
+
+		JWK jwk1 = jwkListResultCaptor.getResult().get(0);
+		NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(((RSAKey) jwk1).toRSAPublicKey()).build();
+		jwtDecoder.decode(encodedJws.getTokenValue());
+
+		jwkSource.rotate(); // Simulate key rotation
+
+		encodedJws = jwsEncoder.encode(joseHeader, jwtClaimsSet);
+
+		JWK jwk2 = jwkListResultCaptor.getResult().get(0);
+		jwtDecoder = NimbusJwtDecoder.withPublicKey(((RSAKey) jwk2).toRSAPublicKey()).build();
+		jwtDecoder.decode(encodedJws.getTokenValue());
+
+		assertThat(jwk1.getKeyID()).isNotEqualTo(jwk2.getKeyID());
+	}
+
+	private static final class JwkListResultCaptor implements Answer<List<JWK>> {
+
+		private List<JWK> result;
+
+		private List<JWK> getResult() {
+			return this.result;
+		}
+
+		@SuppressWarnings("unchecked")
+		@Override
+		public List<JWK> answer(InvocationOnMock invocationOnMock) throws Throwable {
+			this.result = (List<JWK>) invocationOnMock.callRealMethod();
+			return this.result;
+		}
+
+	}
+
+	private static final class TestJWKSource implements JWKSource<SecurityContext> {
+
+		private int keyId = 1000;
+
+		private JWKSet jwkSet;
+
+		private TestJWKSource() {
+			init();
+		}
+
+		@Override
+		public List<JWK> get(JWKSelector jwkSelector, SecurityContext context) {
+			return jwkSelector.select(this.jwkSet);
+		}
+
+		private void init() {
+			// @formatter:off
+			RSAKey rsaJwk = TestJwks.jwk(TestKeys.DEFAULT_PUBLIC_KEY, TestKeys.DEFAULT_PRIVATE_KEY)
+					.keyID("rsa-jwk-" + this.keyId++)
+					.build();
+			ECKey ecJwk = TestJwks.jwk((ECPublicKey) TestKeys.DEFAULT_EC_KEY_PAIR.getPublic(), (ECPrivateKey) TestKeys.DEFAULT_EC_KEY_PAIR.getPrivate())
+					.keyID("ec-jwk-" + this.keyId++)
+					.build();
+			OctetSequenceKey secretJwk = TestJwks.jwk(TestKeys.DEFAULT_SECRET_KEY)
+					.keyID("secret-jwk-" + this.keyId++)
+					.build();
+			// @formatter:on
+			this.jwkSet = new JWKSet(Arrays.asList(rsaJwk, ecJwk, secretJwk));
+		}
+
+		private void rotate() {
+			init();
+		}
+
+	}
+
+}

+ 175 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverterTests.java

@@ -0,0 +1,175 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import java.util.Collections;
+import java.util.function.Function;
+
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.OctetSequenceKey;
+import com.nimbusds.jose.jwk.RSAKey;
+import org.junit.Before;
+import org.junit.Test;
+
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimNames;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.util.MultiValueMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link NimbusJwtClientAuthenticationParametersConverter}.
+ *
+ * @author Joe Grandja
+ */
+public class NimbusJwtClientAuthenticationParametersConverterTests {
+
+	private Function<ClientRegistration, JWK> jwkResolver;
+
+	private NimbusJwtClientAuthenticationParametersConverter<OAuth2ClientCredentialsGrantRequest> converter;
+
+	@Before
+	public void setup() {
+		this.jwkResolver = mock(Function.class);
+		this.converter = new NimbusJwtClientAuthenticationParametersConverter<>(this.jwkResolver);
+	}
+
+	@Test
+	public void constructorWhenJwkResolverNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new NimbusJwtClientAuthenticationParametersConverter<>(null))
+				.withMessage("jwkResolver cannot be null");
+	}
+
+	@Test
+	public void convertWhenAuthorizationGrantRequestNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.convert(null))
+				.withMessage("authorizationGrantRequest cannot be null");
+	}
+
+	@Test
+	public void convertWhenOtherClientAuthenticationMethodThenNotCustomized() {
+		// @formatter:off
+		ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+				.build();
+		// @formatter:on
+		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
+				clientRegistration);
+		assertThat(this.converter.convert(clientCredentialsGrantRequest)).isNull();
+		verifyNoInteractions(this.jwkResolver);
+	}
+
+	@Test
+	public void convertWhenJwkNotResolvedThenThrowOAuth2AuthorizationException() {
+		// @formatter:off
+		ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
+				.build();
+		// @formatter:on
+		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
+				clientRegistration);
+		assertThatExceptionOfType(OAuth2AuthorizationException.class)
+				.isThrownBy(() -> this.converter.convert(clientCredentialsGrantRequest))
+				.withMessage("[invalid_key] Failed to resolve JWK signing key for client registration '"
+						+ clientRegistration.getRegistrationId() + "'.");
+	}
+
+	@Test
+	public void convertWhenPrivateKeyJwtClientAuthenticationMethodThenCustomized() throws Exception {
+		RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK;
+		given(this.jwkResolver.apply(any())).willReturn(rsaJwk);
+
+		// @formatter:off
+		ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
+				.build();
+		// @formatter:on
+
+		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
+				clientRegistration);
+		MultiValueMap<String, String> parameters = this.converter.convert(clientCredentialsGrantRequest);
+
+		assertThat(parameters.getFirst(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE))
+				.isEqualTo("urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
+		String encodedJws = parameters.getFirst(OAuth2ParameterNames.CLIENT_ASSERTION);
+		assertThat(encodedJws).isNotNull();
+
+		NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(rsaJwk.toRSAPublicKey()).build();
+		Jwt jws = jwtDecoder.decode(encodedJws);
+
+		assertThat(jws.getHeaders().get(JoseHeaderNames.ALG)).isEqualTo(SignatureAlgorithm.RS256.getName());
+		assertThat(jws.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(rsaJwk.getKeyID());
+		assertThat(jws.<String>getClaim(JwtClaimNames.ISS)).isEqualTo(clientRegistration.getClientId());
+		assertThat(jws.getSubject()).isEqualTo(clientRegistration.getClientId());
+		assertThat(jws.getAudience())
+				.isEqualTo(Collections.singletonList(clientRegistration.getProviderDetails().getTokenUri()));
+		assertThat(jws.getId()).isNotNull();
+		assertThat(jws.getIssuedAt()).isNotNull();
+		assertThat(jws.getExpiresAt()).isNotNull();
+	}
+
+	@Test
+	public void convertWhenClientSecretJwtClientAuthenticationMethodThenCustomized() {
+		OctetSequenceKey secretJwk = TestJwks.DEFAULT_SECRET_JWK;
+		given(this.jwkResolver.apply(any())).willReturn(secretJwk);
+
+		// @formatter:off
+		ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.build();
+		// @formatter:on
+
+		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
+				clientRegistration);
+		MultiValueMap<String, String> parameters = this.converter.convert(clientCredentialsGrantRequest);
+
+		assertThat(parameters.getFirst(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE))
+				.isEqualTo("urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
+		String encodedJws = parameters.getFirst(OAuth2ParameterNames.CLIENT_ASSERTION);
+		assertThat(encodedJws).isNotNull();
+
+		NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(secretJwk.toSecretKey()).build();
+		Jwt jws = jwtDecoder.decode(encodedJws);
+
+		assertThat(jws.getHeaders().get(JoseHeaderNames.ALG)).isEqualTo(MacAlgorithm.HS256.getName());
+		assertThat(jws.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(secretJwk.getKeyID());
+		assertThat(jws.<String>getClaim(JwtClaimNames.ISS)).isEqualTo(clientRegistration.getClientId());
+		assertThat(jws.getSubject()).isEqualTo(clientRegistration.getClientId());
+		assertThat(jws.getAudience())
+				.isEqualTo(Collections.singletonList(clientRegistration.getProviderDetails().getTokenUri()));
+		assertThat(jws.getId()).isNotNull();
+		assertThat(jws.getIssuedAt()).isNotNull();
+		assertThat(jws.getExpiresAt()).isNotNull();
+	}
+
+}

+ 93 - 54
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2AuthorizationCodeGrantRequestEntityConverterTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,28 +16,36 @@
 
 package org.springframework.security.oauth2.client.endpoint;
 
-import java.util.Arrays;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Map;
 
+import org.junit.Before;
 import org.junit.Test;
+import org.mockito.InOrder;
 
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
 import org.springframework.http.RequestEntity;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
-import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationExchange;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
+import org.springframework.security.oauth2.core.endpoint.TestOAuth2AuthorizationExchanges;
+import org.springframework.security.oauth2.core.endpoint.TestOAuth2AuthorizationRequests;
+import org.springframework.security.oauth2.core.endpoint.TestOAuth2AuthorizationResponses;
 import org.springframework.util.MultiValueMap;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
 
 /**
  * Tests for {@link OAuth2AuthorizationCodeGrantRequestEntityConverter}.
@@ -46,49 +54,78 @@ import static org.assertj.core.api.Assertions.assertThat;
  */
 public class OAuth2AuthorizationCodeGrantRequestEntityConverterTests {
 
-	private OAuth2AuthorizationCodeGrantRequestEntityConverter converter = new OAuth2AuthorizationCodeGrantRequestEntityConverter();
-
-	// @formatter:off
-	private ClientRegistration.Builder clientRegistrationBuilder = ClientRegistration
-			.withRegistrationId("registration-1")
-			.clientId("client-1")
-			.clientSecret("secret")
-			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
-			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
-			.redirectUri("https://client.com/callback/client-1")
-			.scope("read", "write")
-			.authorizationUri("https://provider.com/oauth2/authorize")
-			.tokenUri("https://provider.com/oauth2/token")
-			.userInfoUri("https://provider.com/user")
-			.userNameAttributeName("id")
-			.clientName("client-1");
-	// @formatter:on
-
-	// @formatter:off
-	private OAuth2AuthorizationRequest.Builder authorizationRequestBuilder = OAuth2AuthorizationRequest
-			.authorizationCode()
-			.clientId("client-1")
-			.state("state-1234")
-			.authorizationUri("https://provider.com/oauth2/authorize")
-			.redirectUri("https://client.com/callback/client-1")
-			.scopes(new HashSet(Arrays.asList("read", "write")));
-	// @formatter:on
-
-	// @formatter:off
-	private OAuth2AuthorizationResponse.Builder authorizationResponseBuilder = OAuth2AuthorizationResponse
-			.success("code-1234")
-			.state("state-1234")
-			.redirectUri("https://client.com/callback/client-1");
-	// @formatter:on
+	private OAuth2AuthorizationCodeGrantRequestEntityConverter converter;
+
+	@Before
+	public void setup() {
+		this.converter = new OAuth2AuthorizationCodeGrantRequestEntityConverter();
+	}
+
+	@Test
+	public void setHeadersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.setHeadersConverter(null))
+				.withMessage("headersConverter cannot be null");
+	}
+
+	@Test
+	public void addHeadersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.addHeadersConverter(null))
+				.withMessage("headersConverter cannot be null");
+	}
+
+	@Test
+	public void setParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.setParametersConverter(null))
+				.withMessage("parametersConverter cannot be null");
+	}
+
+	@Test
+	public void addParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.addParametersConverter(null))
+				.withMessage("parametersConverter cannot be null");
+	}
+
+	@Test
+	public void convertWhenHeadersConverterSetThenCalled() {
+		Converter<OAuth2AuthorizationCodeGrantRequest, HttpHeaders> headersConverter1 = mock(Converter.class);
+		this.converter.setHeadersConverter(headersConverter1);
+		Converter<OAuth2AuthorizationCodeGrantRequest, HttpHeaders> headersConverter2 = mock(Converter.class);
+		this.converter.addHeadersConverter(headersConverter2);
+		ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build();
+		OAuth2AuthorizationExchange authorizationExchange = TestOAuth2AuthorizationExchanges.success();
+		OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest = new OAuth2AuthorizationCodeGrantRequest(
+				clientRegistration, authorizationExchange);
+		this.converter.convert(authorizationCodeGrantRequest);
+		InOrder inOrder = inOrder(headersConverter1, headersConverter2);
+		inOrder.verify(headersConverter1).convert(any(OAuth2AuthorizationCodeGrantRequest.class));
+		inOrder.verify(headersConverter2).convert(any(OAuth2AuthorizationCodeGrantRequest.class));
+	}
+
+	@Test
+	public void convertWhenParametersConverterSetThenCalled() {
+		Converter<OAuth2AuthorizationCodeGrantRequest, MultiValueMap<String, String>> parametersConverter1 = mock(
+				Converter.class);
+		this.converter.setParametersConverter(parametersConverter1);
+		Converter<OAuth2AuthorizationCodeGrantRequest, MultiValueMap<String, String>> parametersConverter2 = mock(
+				Converter.class);
+		this.converter.addParametersConverter(parametersConverter2);
+		ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build();
+		OAuth2AuthorizationExchange authorizationExchange = TestOAuth2AuthorizationExchanges.success();
+		OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest = new OAuth2AuthorizationCodeGrantRequest(
+				clientRegistration, authorizationExchange);
+		this.converter.convert(authorizationCodeGrantRequest);
+		InOrder inOrder = inOrder(parametersConverter1, parametersConverter2);
+		inOrder.verify(parametersConverter1).convert(any(OAuth2AuthorizationCodeGrantRequest.class));
+		inOrder.verify(parametersConverter2).convert(any(OAuth2AuthorizationCodeGrantRequest.class));
+	}
 
 	@SuppressWarnings("unchecked")
 	@Test
 	public void convertWhenGrantRequestValidThenConverts() {
-		ClientRegistration clientRegistration = this.clientRegistrationBuilder.build();
-		OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestBuilder.build();
-		OAuth2AuthorizationResponse authorizationResponse = this.authorizationResponseBuilder.build();
-		OAuth2AuthorizationExchange authorizationExchange = new OAuth2AuthorizationExchange(authorizationRequest,
-				authorizationResponse);
+		ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build();
+		OAuth2AuthorizationExchange authorizationExchange = TestOAuth2AuthorizationExchanges.success();
+		OAuth2AuthorizationRequest authorizationRequest = authorizationExchange.getAuthorizationRequest();
+		OAuth2AuthorizationResponse authorizationResponse = authorizationExchange.getAuthorizationResponse();
 		OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest = new OAuth2AuthorizationCodeGrantRequest(
 				clientRegistration, authorizationExchange);
 		RequestEntity<?> requestEntity = this.converter.convert(authorizationCodeGrantRequest);
@@ -103,25 +140,25 @@ public class OAuth2AuthorizationCodeGrantRequestEntityConverterTests {
 		MultiValueMap<String, String> formParameters = (MultiValueMap<String, String>) requestEntity.getBody();
 		assertThat(formParameters.getFirst(OAuth2ParameterNames.GRANT_TYPE))
 				.isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
-		assertThat(formParameters.getFirst(OAuth2ParameterNames.CODE)).isEqualTo("code-1234");
+		assertThat(formParameters.getFirst(OAuth2ParameterNames.CODE)).isEqualTo(authorizationResponse.getCode());
 		assertThat(formParameters.getFirst(OAuth2ParameterNames.CLIENT_ID)).isNull();
 		assertThat(formParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI))
-				.isEqualTo(clientRegistration.getRedirectUri());
+				.isEqualTo(authorizationRequest.getRedirectUri());
 	}
 
 	@SuppressWarnings("unchecked")
 	@Test
 	public void convertWhenPkceGrantRequestValidThenConverts() {
-		ClientRegistration clientRegistration = this.clientRegistrationBuilder.clientAuthenticationMethod(null)
-				.clientSecret(null).build();
+		ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration()
+				.clientAuthenticationMethod(null).clientSecret(null).build();
 		Map<String, Object> attributes = new HashMap<>();
 		attributes.put(PkceParameterNames.CODE_VERIFIER, "code-verifier-1234");
 		Map<String, Object> additionalParameters = new HashMap<>();
 		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge-1234");
 		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
-		OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestBuilder.attributes(attributes)
-				.additionalParameters(additionalParameters).build();
-		OAuth2AuthorizationResponse authorizationResponse = this.authorizationResponseBuilder.build();
+		OAuth2AuthorizationRequest authorizationRequest = TestOAuth2AuthorizationRequests.request()
+				.attributes(attributes).additionalParameters(additionalParameters).build();
+		OAuth2AuthorizationResponse authorizationResponse = TestOAuth2AuthorizationResponses.success().build();
 		OAuth2AuthorizationExchange authorizationExchange = new OAuth2AuthorizationExchange(authorizationRequest,
 				authorizationResponse);
 		OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest = new OAuth2AuthorizationCodeGrantRequest(
@@ -138,11 +175,13 @@ public class OAuth2AuthorizationCodeGrantRequestEntityConverterTests {
 		MultiValueMap<String, String> formParameters = (MultiValueMap<String, String>) requestEntity.getBody();
 		assertThat(formParameters.getFirst(OAuth2ParameterNames.GRANT_TYPE))
 				.isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
-		assertThat(formParameters.getFirst(OAuth2ParameterNames.CODE)).isEqualTo("code-1234");
+		assertThat(formParameters.getFirst(OAuth2ParameterNames.CODE)).isEqualTo(authorizationResponse.getCode());
 		assertThat(formParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI))
-				.isEqualTo(clientRegistration.getRedirectUri());
-		assertThat(formParameters.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo("client-1");
-		assertThat(formParameters.getFirst(PkceParameterNames.CODE_VERIFIER)).isEqualTo("code-verifier-1234");
+				.isEqualTo(authorizationRequest.getRedirectUri());
+		assertThat(formParameters.getFirst(OAuth2ParameterNames.CLIENT_ID))
+				.isEqualTo(authorizationRequest.getClientId());
+		assertThat(formParameters.getFirst(PkceParameterNames.CODE_VERIFIER))
+				.isEqualTo(authorizationRequest.getAttribute(PkceParameterNames.CODE_VERIFIER));
 	}
 
 }

+ 71 - 19
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2ClientCredentialsGrantRequestEntityConverterTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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,18 +18,24 @@ package org.springframework.security.oauth2.client.endpoint;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.InOrder;
 
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
 import org.springframework.http.RequestEntity;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
 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.util.MultiValueMap;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
 
 /**
  * Tests for {@link OAuth2ClientCredentialsGrantRequestEntityConverter}.
@@ -38,30 +44,76 @@ import static org.assertj.core.api.Assertions.assertThat;
  */
 public class OAuth2ClientCredentialsGrantRequestEntityConverterTests {
 
-	private OAuth2ClientCredentialsGrantRequestEntityConverter converter = new OAuth2ClientCredentialsGrantRequestEntityConverter();
-
-	private OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest;
+	private OAuth2ClientCredentialsGrantRequestEntityConverter converter;
 
 	@Before
 	public void setup() {
-		// @formatter:off
-		ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("registration-1")
-				.clientId("client-1")
-				.clientSecret("secret")
-				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
-				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
-				.scope("read", "write")
-				.tokenUri("https://provider.com/oauth2/token")
-				.build();
-		// @formatter:on
-		this.clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(clientRegistration);
+		this.converter = new OAuth2ClientCredentialsGrantRequestEntityConverter();
+	}
+
+	@Test
+	public void setHeadersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.setHeadersConverter(null))
+				.withMessage("headersConverter cannot be null");
+	}
+
+	@Test
+	public void addHeadersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.addHeadersConverter(null))
+				.withMessage("headersConverter cannot be null");
+	}
+
+	@Test
+	public void setParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.setParametersConverter(null))
+				.withMessage("parametersConverter cannot be null");
+	}
+
+	@Test
+	public void addParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.addParametersConverter(null))
+				.withMessage("parametersConverter cannot be null");
+	}
+
+	@Test
+	public void convertWhenHeadersConverterSetThenCalled() {
+		Converter<OAuth2ClientCredentialsGrantRequest, HttpHeaders> headersConverter1 = mock(Converter.class);
+		this.converter.setHeadersConverter(headersConverter1);
+		Converter<OAuth2ClientCredentialsGrantRequest, HttpHeaders> headersConverter2 = mock(Converter.class);
+		this.converter.addHeadersConverter(headersConverter2);
+		ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials().build();
+		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
+				clientRegistration);
+		this.converter.convert(clientCredentialsGrantRequest);
+		InOrder inOrder = inOrder(headersConverter1, headersConverter2);
+		inOrder.verify(headersConverter1).convert(any(OAuth2ClientCredentialsGrantRequest.class));
+		inOrder.verify(headersConverter2).convert(any(OAuth2ClientCredentialsGrantRequest.class));
+	}
+
+	@Test
+	public void convertWhenParametersConverterSetThenCalled() {
+		Converter<OAuth2ClientCredentialsGrantRequest, MultiValueMap<String, String>> parametersConverter1 = mock(
+				Converter.class);
+		this.converter.setParametersConverter(parametersConverter1);
+		Converter<OAuth2ClientCredentialsGrantRequest, MultiValueMap<String, String>> parametersConverter2 = mock(
+				Converter.class);
+		this.converter.addParametersConverter(parametersConverter2);
+		ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials().build();
+		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
+				clientRegistration);
+		this.converter.convert(clientCredentialsGrantRequest);
+		InOrder inOrder = inOrder(parametersConverter1, parametersConverter2);
+		inOrder.verify(parametersConverter1).convert(any(OAuth2ClientCredentialsGrantRequest.class));
+		inOrder.verify(parametersConverter2).convert(any(OAuth2ClientCredentialsGrantRequest.class));
 	}
 
 	@SuppressWarnings("unchecked")
 	@Test
 	public void convertWhenGrantRequestValidThenConverts() {
-		RequestEntity<?> requestEntity = this.converter.convert(this.clientCredentialsGrantRequest);
-		ClientRegistration clientRegistration = this.clientCredentialsGrantRequest.getClientRegistration();
+		ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials().build();
+		OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
+				clientRegistration);
+		RequestEntity<?> requestEntity = this.converter.convert(clientCredentialsGrantRequest);
 		assertThat(requestEntity.getMethod()).isEqualTo(HttpMethod.POST);
 		assertThat(requestEntity.getUrl().toASCIIString())
 				.isEqualTo(clientRegistration.getProviderDetails().getTokenUri());
@@ -73,7 +125,7 @@ public class OAuth2ClientCredentialsGrantRequestEntityConverterTests {
 		MultiValueMap<String, String> formParameters = (MultiValueMap<String, String>) requestEntity.getBody();
 		assertThat(formParameters.getFirst(OAuth2ParameterNames.GRANT_TYPE))
 				.isEqualTo(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue());
-		assertThat(formParameters.getFirst(OAuth2ParameterNames.SCOPE)).isEqualTo("read write");
+		assertThat(formParameters.getFirst(OAuth2ParameterNames.SCOPE)).contains(clientRegistration.getScopes());
 	}
 
 }

+ 70 - 14
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2PasswordGrantRequestEntityConverterTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2019 the original author or authors.
+ * Copyright 2002-2021 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,7 +18,9 @@ package org.springframework.security.oauth2.client.endpoint;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.InOrder;
 
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
@@ -30,6 +32,10 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.util.MultiValueMap;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
 
 /**
  * Tests for {@link OAuth2PasswordGrantRequestEntityConverter}.
@@ -38,26 +44,76 @@ import static org.assertj.core.api.Assertions.assertThat;
  */
 public class OAuth2PasswordGrantRequestEntityConverterTests {
 
-	private OAuth2PasswordGrantRequestEntityConverter converter = new OAuth2PasswordGrantRequestEntityConverter();
-
-	private OAuth2PasswordGrantRequest passwordGrantRequest;
+	private OAuth2PasswordGrantRequestEntityConverter converter;
 
 	@Before
 	public void setup() {
-		// @formatter:off
-		ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration()
-				.authorizationGrantType(AuthorizationGrantType.PASSWORD)
-				.scope("read", "write")
-				.build();
-		// @formatter:on
-		this.passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration, "user1", "password");
+		this.converter = new OAuth2PasswordGrantRequestEntityConverter();
+	}
+
+	@Test
+	public void setHeadersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.setHeadersConverter(null))
+				.withMessage("headersConverter cannot be null");
+	}
+
+	@Test
+	public void addHeadersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.addHeadersConverter(null))
+				.withMessage("headersConverter cannot be null");
+	}
+
+	@Test
+	public void setParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.setParametersConverter(null))
+				.withMessage("parametersConverter cannot be null");
+	}
+
+	@Test
+	public void addParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.addParametersConverter(null))
+				.withMessage("parametersConverter cannot be null");
+	}
+
+	@Test
+	public void convertWhenHeadersConverterSetThenCalled() {
+		Converter<OAuth2PasswordGrantRequest, HttpHeaders> headersConverter1 = mock(Converter.class);
+		this.converter.setHeadersConverter(headersConverter1);
+		Converter<OAuth2PasswordGrantRequest, HttpHeaders> headersConverter2 = mock(Converter.class);
+		this.converter.addHeadersConverter(headersConverter2);
+		ClientRegistration clientRegistration = TestClientRegistrations.password().build();
+		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration, "user1",
+				"password");
+		this.converter.convert(passwordGrantRequest);
+		InOrder inOrder = inOrder(headersConverter1, headersConverter2);
+		inOrder.verify(headersConverter1).convert(any(OAuth2PasswordGrantRequest.class));
+		inOrder.verify(headersConverter2).convert(any(OAuth2PasswordGrantRequest.class));
+	}
+
+	@Test
+	public void convertWhenParametersConverterSetThenCalled() {
+		Converter<OAuth2PasswordGrantRequest, MultiValueMap<String, String>> parametersConverter1 = mock(
+				Converter.class);
+		this.converter.setParametersConverter(parametersConverter1);
+		Converter<OAuth2PasswordGrantRequest, MultiValueMap<String, String>> parametersConverter2 = mock(
+				Converter.class);
+		this.converter.addParametersConverter(parametersConverter2);
+		ClientRegistration clientRegistration = TestClientRegistrations.password().build();
+		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration, "user1",
+				"password");
+		this.converter.convert(passwordGrantRequest);
+		InOrder inOrder = inOrder(parametersConverter1, parametersConverter2);
+		inOrder.verify(parametersConverter1).convert(any(OAuth2PasswordGrantRequest.class));
+		inOrder.verify(parametersConverter2).convert(any(OAuth2PasswordGrantRequest.class));
 	}
 
 	@SuppressWarnings("unchecked")
 	@Test
 	public void convertWhenGrantRequestValidThenConverts() {
-		RequestEntity<?> requestEntity = this.converter.convert(this.passwordGrantRequest);
-		ClientRegistration clientRegistration = this.passwordGrantRequest.getClientRegistration();
+		ClientRegistration clientRegistration = TestClientRegistrations.password().build();
+		OAuth2PasswordGrantRequest passwordGrantRequest = new OAuth2PasswordGrantRequest(clientRegistration, "user1",
+				"password");
+		RequestEntity<?> requestEntity = this.converter.convert(passwordGrantRequest);
 		assertThat(requestEntity.getMethod()).isEqualTo(HttpMethod.POST);
 		assertThat(requestEntity.getUrl().toASCIIString())
 				.isEqualTo(clientRegistration.getProviderDetails().getTokenUri());
@@ -71,7 +127,7 @@ public class OAuth2PasswordGrantRequestEntityConverterTests {
 				.isEqualTo(AuthorizationGrantType.PASSWORD.getValue());
 		assertThat(formParameters.getFirst(OAuth2ParameterNames.USERNAME)).isEqualTo("user1");
 		assertThat(formParameters.getFirst(OAuth2ParameterNames.PASSWORD)).isEqualTo("password");
-		assertThat(formParameters.getFirst(OAuth2ParameterNames.SCOPE)).isEqualTo("read write");
+		assertThat(formParameters.getFirst(OAuth2ParameterNames.SCOPE)).contains(clientRegistration.getScopes());
 	}
 
 }

+ 76 - 10
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2RefreshTokenGrantRequestEntityConverterTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2019 the original author or authors.
+ * Copyright 2002-2021 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.
@@ -20,7 +20,9 @@ import java.util.Collections;
 
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.InOrder;
 
+import org.springframework.core.convert.converter.Converter;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.MediaType;
@@ -28,6 +30,7 @@ import org.springframework.http.RequestEntity;
 import org.springframework.security.oauth2.client.registration.ClientRegistration;
 import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
 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.TestOAuth2AccessTokens;
 import org.springframework.security.oauth2.core.TestOAuth2RefreshTokens;
@@ -35,6 +38,10 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.util.MultiValueMap;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
 
 /**
  * Tests for {@link OAuth2RefreshTokenGrantRequestEntityConverter}.
@@ -43,23 +50,82 @@ import static org.assertj.core.api.Assertions.assertThat;
  */
 public class OAuth2RefreshTokenGrantRequestEntityConverterTests {
 
-	private OAuth2RefreshTokenGrantRequestEntityConverter converter = new OAuth2RefreshTokenGrantRequestEntityConverter();
-
-	private OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest;
+	private OAuth2RefreshTokenGrantRequestEntityConverter converter;
 
 	@Before
 	public void setup() {
-		this.refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(
-				TestClientRegistrations.clientRegistration().build(), TestOAuth2AccessTokens.scopes("read", "write"),
-				TestOAuth2RefreshTokens.refreshToken(), Collections.singleton("read"));
+		this.converter = new OAuth2RefreshTokenGrantRequestEntityConverter();
+	}
+
+	@Test
+	public void setHeadersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.setHeadersConverter(null))
+				.withMessage("headersConverter cannot be null");
+	}
+
+	@Test
+	public void addHeadersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.addHeadersConverter(null))
+				.withMessage("headersConverter cannot be null");
+	}
+
+	@Test
+	public void setParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.setParametersConverter(null))
+				.withMessage("parametersConverter cannot be null");
+	}
+
+	@Test
+	public void addParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.converter.addParametersConverter(null))
+				.withMessage("parametersConverter cannot be null");
+	}
+
+	@Test
+	public void convertWhenHeadersConverterSetThenCalled() {
+		Converter<OAuth2RefreshTokenGrantRequest, HttpHeaders> headersConverter1 = mock(Converter.class);
+		this.converter.setHeadersConverter(headersConverter1);
+		Converter<OAuth2RefreshTokenGrantRequest, HttpHeaders> headersConverter2 = mock(Converter.class);
+		this.converter.addHeadersConverter(headersConverter2);
+		ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build();
+		OAuth2AccessToken accessToken = TestOAuth2AccessTokens.scopes("read", "write");
+		OAuth2RefreshToken refreshToken = TestOAuth2RefreshTokens.refreshToken();
+		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(clientRegistration,
+				accessToken, refreshToken);
+		this.converter.convert(refreshTokenGrantRequest);
+		InOrder inOrder = inOrder(headersConverter1, headersConverter2);
+		inOrder.verify(headersConverter1).convert(any(OAuth2RefreshTokenGrantRequest.class));
+		inOrder.verify(headersConverter2).convert(any(OAuth2RefreshTokenGrantRequest.class));
+	}
+
+	@Test
+	public void convertWhenParametersConverterSetThenCalled() {
+		Converter<OAuth2RefreshTokenGrantRequest, MultiValueMap<String, String>> parametersConverter1 = mock(
+				Converter.class);
+		this.converter.setParametersConverter(parametersConverter1);
+		Converter<OAuth2RefreshTokenGrantRequest, MultiValueMap<String, String>> parametersConverter2 = mock(
+				Converter.class);
+		this.converter.addParametersConverter(parametersConverter2);
+		ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build();
+		OAuth2AccessToken accessToken = TestOAuth2AccessTokens.scopes("read", "write");
+		OAuth2RefreshToken refreshToken = TestOAuth2RefreshTokens.refreshToken();
+		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(clientRegistration,
+				accessToken, refreshToken);
+		this.converter.convert(refreshTokenGrantRequest);
+		InOrder inOrder = inOrder(parametersConverter1, parametersConverter2);
+		inOrder.verify(parametersConverter1).convert(any(OAuth2RefreshTokenGrantRequest.class));
+		inOrder.verify(parametersConverter2).convert(any(OAuth2RefreshTokenGrantRequest.class));
 	}
 
 	@SuppressWarnings("unchecked")
 	@Test
 	public void convertWhenGrantRequestValidThenConverts() {
-		RequestEntity<?> requestEntity = this.converter.convert(this.refreshTokenGrantRequest);
-		ClientRegistration clientRegistration = this.refreshTokenGrantRequest.getClientRegistration();
-		OAuth2RefreshToken refreshToken = this.refreshTokenGrantRequest.getRefreshToken();
+		ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build();
+		OAuth2AccessToken accessToken = TestOAuth2AccessTokens.scopes("read", "write");
+		OAuth2RefreshToken refreshToken = TestOAuth2RefreshTokens.refreshToken();
+		OAuth2RefreshTokenGrantRequest refreshTokenGrantRequest = new OAuth2RefreshTokenGrantRequest(clientRegistration,
+				accessToken, refreshToken, Collections.singleton("read"));
+		RequestEntity<?> requestEntity = this.converter.convert(refreshTokenGrantRequest);
 		assertThat(requestEntity.getMethod()).isEqualTo(HttpMethod.POST);
 		assertThat(requestEntity.getUrl().toASCIIString())
 				.isEqualTo(clientRegistration.getProviderDetails().getTokenUri());

+ 76 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJoseHeaders.java

@@ -0,0 +1,76 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * @author Joe Grandja
+ */
+final class TestJoseHeaders {
+
+	private TestJoseHeaders() {
+	}
+
+	static JoseHeader.Builder joseHeader() {
+		return joseHeader(SignatureAlgorithm.RS256);
+	}
+
+	static JoseHeader.Builder joseHeader(SignatureAlgorithm signatureAlgorithm) {
+		// @formatter:off
+		return JoseHeader.withAlgorithm(signatureAlgorithm)
+				.jwkSetUrl("https://provider.com/oauth2/jwks")
+				.jwk(rsaJwk())
+				.keyId("keyId")
+				.x509Url("https://provider.com/oauth2/x509")
+				.x509CertificateChain(Arrays.asList("x509Cert1", "x509Cert2"))
+				.x509SHA1Thumbprint("x509SHA1Thumbprint")
+				.x509SHA256Thumbprint("x509SHA256Thumbprint")
+				.type("JWT")
+				.contentType("jwt-content-type")
+				.header("custom-header-name", "custom-header-value");
+		// @formatter:on
+	}
+
+	private static Map<String, Object> rsaJwk() {
+		Map<String, Object> rsaJwk = new HashMap<>();
+		rsaJwk.put("kty", "RSA");
+		rsaJwk.put("n", "modulus");
+		rsaJwk.put("e", "exponent");
+		return rsaJwk;
+	}
+
+}

+ 64 - 0
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJwtClaimsSets.java

@@ -0,0 +1,64 @@
+/*
+ * Copyright 2002-2021 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.client.endpoint;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+
+/*
+ * NOTE:
+ * This originated in gh-9208 (JwtEncoder),
+ * which is required to realize the feature in gh-8175 (JWT Client Authentication).
+ * However, we decided not to merge gh-9208 as part of the 5.5.0 release
+ * and instead packaged it up privately with the gh-8175 feature.
+ * We MAY merge gh-9208 in a later release but that is yet to be determined.
+ *
+ * gh-9208 Introduce JwtEncoder
+ * https://github.com/spring-projects/spring-security/pull/9208
+ *
+ * gh-8175 Support JWT for Client Authentication
+ * https://github.com/spring-projects/spring-security/issues/8175
+ */
+
+/**
+ * @author Joe Grandja
+ */
+final class TestJwtClaimsSets {
+
+	private TestJwtClaimsSets() {
+	}
+
+	static JwtClaimsSet.Builder jwtClaimsSet() {
+		String issuer = "https://provider.com";
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+
+		// @formatter:off
+		return JwtClaimsSet.builder()
+				.issuer(issuer)
+				.subject("subject")
+				.audience(Collections.singletonList("client-1"))
+				.issuedAt(issuedAt)
+				.notBefore(issuedAt)
+				.expiresAt(expiresAt)
+				.id("jti")
+				.claim("custom-claim-name", "custom-claim-value");
+		// @formatter:on
+	}
+
+}

+ 12 - 1
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClientAuthenticationMethod.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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.
@@ -58,6 +58,17 @@ public final class ClientAuthenticationMethod implements Serializable {
 	public static final ClientAuthenticationMethod CLIENT_SECRET_POST = new ClientAuthenticationMethod(
 			"client_secret_post");
 
+	/**
+	 * @since 5.5
+	 */
+	public static final ClientAuthenticationMethod CLIENT_SECRET_JWT = new ClientAuthenticationMethod(
+			"client_secret_jwt");
+
+	/**
+	 * @since 5.5
+	 */
+	public static final ClientAuthenticationMethod PRIVATE_KEY_JWT = new ClientAuthenticationMethod("private_key_jwt");
+
 	/**
 	 * @since 5.2
 	 */

+ 13 - 1
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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.
@@ -48,6 +48,18 @@ public interface OAuth2ParameterNames {
 	 */
 	String CLIENT_SECRET = "client_secret";
 
+	/**
+	 * {@code client_assertion_type} - used in Access Token Request.
+	 * @since 5.5
+	 */
+	String CLIENT_ASSERTION_TYPE = "client_assertion_type";
+
+	/**
+	 * {@code client_assertion} - used in Access Token Request.
+	 * @since 5.5
+	 */
+	String CLIENT_ASSERTION = "client_assertion";
+
 	/**
 	 * {@code redirect_uri} - used in Authorization Request and Access Token Request.
 	 */

+ 11 - 1
oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/ClientAuthenticationMethodTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2020 the original author or authors.
+ * Copyright 2002-2021 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.
@@ -53,6 +53,16 @@ public class ClientAuthenticationMethodTests {
 		assertThat(ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue()).isEqualTo("client_secret_post");
 	}
 
+	@Test
+	public void getValueWhenAuthenticationMethodClientSecretJwtThenReturnClientSecretJwt() {
+		assertThat(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()).isEqualTo("client_secret_jwt");
+	}
+
+	@Test
+	public void getValueWhenAuthenticationMethodPrivateKeyJwtThenReturnPrivateKeyJwt() {
+		assertThat(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue()).isEqualTo("private_key_jwt");
+	}
+
 	@Test
 	public void getValueWhenAuthenticationMethodNoneThenReturnNone() {
 		assertThat(ClientAuthenticationMethod.NONE.getValue()).isEqualTo("none");

+ 86 - 0
oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/TestJwks.java

@@ -0,0 +1,86 @@
+/*
+ * Copyright 2002-2021 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.jose;
+
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+
+import javax.crypto.SecretKey;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.OctetSequenceKey;
+import com.nimbusds.jose.jwk.RSAKey;
+
+/**
+ * @author Joe Grandja
+ */
+public final class TestJwks {
+
+	// @formatter:off
+	public static final RSAKey DEFAULT_RSA_JWK =
+			jwk(
+					TestKeys.DEFAULT_PUBLIC_KEY,
+					TestKeys.DEFAULT_PRIVATE_KEY
+			).build();
+	// @formatter:on
+
+	// @formatter:off
+	public static final ECKey DEFAULT_EC_JWK =
+			jwk(
+					(ECPublicKey) TestKeys.DEFAULT_EC_KEY_PAIR.getPublic(),
+					(ECPrivateKey) TestKeys.DEFAULT_EC_KEY_PAIR.getPrivate()
+			).build();
+	// @formatter:on
+
+	// @formatter:off
+	public static final OctetSequenceKey DEFAULT_SECRET_JWK =
+			jwk(
+					TestKeys.DEFAULT_SECRET_KEY
+			).build();
+	// @formatter:on
+
+	private TestJwks() {
+	}
+
+	public static RSAKey.Builder jwk(RSAPublicKey publicKey, RSAPrivateKey privateKey) {
+		// @formatter:off
+		return new RSAKey.Builder(publicKey)
+				.privateKey(privateKey)
+				.keyID("rsa-jwk-kid");
+		// @formatter:on
+	}
+
+	public static ECKey.Builder jwk(ECPublicKey publicKey, ECPrivateKey privateKey) {
+		// @formatter:off
+		Curve curve = Curve.forECParameterSpec(publicKey.getParams());
+		return new ECKey.Builder(curve, publicKey)
+				.privateKey(privateKey)
+				.keyID("ec-jwk-kid");
+		// @formatter:on
+	}
+
+	public static OctetSequenceKey.Builder jwk(SecretKey secretKey) {
+		// @formatter:off
+		return new OctetSequenceKey.Builder(secretKey)
+				.keyID("secret-jwk-kid");
+		// @formatter:on
+	}
+
+}

+ 36 - 1
oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/TestKeys.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2019 the original author or authors.
+ * Copyright 2002-2021 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,10 +16,17 @@
 
 package org.springframework.security.oauth2.jose;
 
+import java.math.BigInteger;
 import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
 import java.security.NoSuchAlgorithmException;
 import java.security.interfaces.RSAPrivateKey;
 import java.security.interfaces.RSAPublicKey;
+import java.security.spec.ECFieldFp;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.EllipticCurve;
 import java.security.spec.InvalidKeySpecException;
 import java.security.spec.PKCS8EncodedKeySpec;
 import java.security.spec.X509EncodedKeySpec;
@@ -109,6 +116,34 @@ public final class TestKeys {
 		}
 	}
 
+	public static final KeyPair DEFAULT_RSA_KEY_PAIR = new KeyPair(DEFAULT_PUBLIC_KEY, DEFAULT_PRIVATE_KEY);
+
+	public static final KeyPair DEFAULT_EC_KEY_PAIR = generateEcKeyPair();
+
+	static KeyPair generateEcKeyPair() {
+		EllipticCurve ellipticCurve = new EllipticCurve(
+				new ECFieldFp(new BigInteger(
+						"115792089210356248762697446949407573530086143415290314195533631308867097853951")),
+				new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853948"),
+				new BigInteger("41058363725152142129326129780047268409114441015993725554835256314039467401291"));
+		ECPoint ecPoint = new ECPoint(
+				new BigInteger("48439561293906451759052585252797914202762949526041747995844080717082404635286"),
+				new BigInteger("36134250956749795798585127919587881956611106672985015071877198253568414405109"));
+		ECParameterSpec ecParameterSpec = new ECParameterSpec(ellipticCurve, ecPoint,
+				new BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369"), 1);
+
+		KeyPair keyPair;
+		try {
+			KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
+			keyPairGenerator.initialize(ecParameterSpec);
+			keyPair = keyPairGenerator.generateKeyPair();
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+		return keyPair;
+	}
+
 	private TestKeys() {
 	}