Przeglądaj źródła

Introduce JwtEncoder

Closes gh-9208
Joe Grandja 4 lat temu
rodzic
commit
5830fda2fa
18 zmienionych plików z 1069 dodań i 457 usunięć
  1. 1 0
      etc/checkstyle/checkstyle-suppressions.xml
  2. 13 8
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverter.java
  3. 0 123
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JoseHeaderTests.java
  4. 1 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverterTests.java
  5. 67 94
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JoseHeader.java
  6. 14 30
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JoseHeaderNames.java
  7. 90 0
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwsHeader.java
  8. 17 35
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimsSet.java
  9. 57 0
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoder.java
  10. 83 0
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoderParameters.java
  11. 5 22
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java
  12. 29 36
      oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.java
  13. 111 0
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwsHeaderTests.java
  14. 3 18
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtClaimsSetTests.java
  15. 518 0
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJweEncoderTests.java
  16. 50 51
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoderTests.java
  17. 7 22
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwsHeaders.java
  18. 3 18
      oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwtClaimsSets.java

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

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

+ 13 - 8
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverter.java

@@ -40,7 +40,12 @@ 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.JwsHeader;
 import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
 import org.springframework.util.Assert;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
@@ -122,7 +127,7 @@ public final class NimbusJwtClientAuthenticationParametersConverter<T extends Ab
 			throw new OAuth2AuthorizationException(oauth2Error);
 		}
 
-		JoseHeader.Builder headersBuilder = JoseHeader.withAlgorithm(jwsAlgorithm);
+		JwsHeader.Builder headersBuilder = JwsHeader.with(jwsAlgorithm);
 
 		Instant issuedAt = Instant.now();
 		Instant expiresAt = issuedAt.plus(Duration.ofSeconds(60));
@@ -137,7 +142,7 @@ public final class NimbusJwtClientAuthenticationParametersConverter<T extends Ab
 				.expiresAt(expiresAt);
 		// @formatter:on
 
-		JoseHeader joseHeader = headersBuilder.build();
+		JwsHeader jwsHeader = headersBuilder.build();
 		JwtClaimsSet jwtClaimsSet = claimsBuilder.build();
 
 		JwsEncoderHolder jwsEncoderHolder = this.jwsEncoders.compute(clientRegistration.getRegistrationId(),
@@ -146,11 +151,11 @@ public final class NimbusJwtClientAuthenticationParametersConverter<T extends Ab
 						return currentJwsEncoderHolder;
 					}
 					JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk));
-					return new JwsEncoderHolder(new NimbusJwsEncoder(jwkSource), jwk);
+					return new JwsEncoderHolder(new NimbusJwtEncoder(jwkSource), jwk);
 				});
 
-		NimbusJwsEncoder jwsEncoder = jwsEncoderHolder.getJwsEncoder();
-		Jwt jws = jwsEncoder.encode(joseHeader, jwtClaimsSet);
+		JwtEncoder jwsEncoder = jwsEncoderHolder.getJwsEncoder();
+		Jwt jws = jwsEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
 
 		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
 		parameters.set(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE, CLIENT_ASSERTION_TYPE_VALUE);
@@ -186,16 +191,16 @@ public final class NimbusJwtClientAuthenticationParametersConverter<T extends Ab
 
 	private static final class JwsEncoderHolder {
 
-		private final NimbusJwsEncoder jwsEncoder;
+		private final JwtEncoder jwsEncoder;
 
 		private final JWK jwk;
 
-		private JwsEncoderHolder(NimbusJwsEncoder jwsEncoder, JWK jwk) {
+		private JwsEncoderHolder(JwtEncoder jwsEncoder, JWK jwk) {
 			this.jwsEncoder = jwsEncoder;
 			this.jwk = jwk;
 		}
 
-		private NimbusJwsEncoder getJwsEncoder() {
+		private JwtEncoder getJwsEncoder() {
 			return this.jwsEncoder;
 		}
 

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

@@ -1,123 +0,0 @@
-/*
- * 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.jupiter.api.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");
-	}
-
-}

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

@@ -38,6 +38,7 @@ 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.JoseHeaderNames;
 import org.springframework.security.oauth2.jwt.Jwt;
 import org.springframework.security.oauth2.jwt.JwtClaimNames;
 import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

+ 67 - 94
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeader.java → oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JoseHeader.java

@@ -14,11 +14,12 @@
  * limitations under the License.
  */
 
-package org.springframework.security.oauth2.client.endpoint;
+package org.springframework.security.oauth2.jwt;
 
 import java.net.URL;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -26,24 +27,8 @@ 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
@@ -51,7 +36,7 @@ import org.springframework.util.Assert;
  *
  * @author Anoop Garlapati
  * @author Joe Grandja
- * @since 5.5
+ * @since 5.6
  * @see Jwt
  * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7519#section-5">JWT JOSE
  * Header</a>
@@ -60,11 +45,12 @@ import org.springframework.util.Assert;
  * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7516#section-4">JWE JOSE
  * Header</a>
  */
-final class JoseHeader {
+class JoseHeader {
 
 	private final Map<String, Object> headers;
 
-	private JoseHeader(Map<String, Object> headers) {
+	protected JoseHeader(Map<String, Object> headers) {
+		Assert.notEmpty(headers, "headers cannot be empty");
 		this.headers = Collections.unmodifiableMap(new HashMap<>(headers));
 	}
 
@@ -74,7 +60,7 @@ final class JoseHeader {
 	 * @return the {@link JwaAlgorithm}
 	 */
 	@SuppressWarnings("unchecked")
-	<T extends JwaAlgorithm> T getAlgorithm() {
+	public <T extends JwaAlgorithm> T getAlgorithm() {
 		return (T) getHeader(JoseHeaderNames.ALG);
 	}
 
@@ -84,7 +70,7 @@ final class JoseHeader {
 	 * the JWE.
 	 * @return the JWK Set URL
 	 */
-	URL getJwkSetUrl() {
+	public URL getJwkSetUrl() {
 		return getHeader(JoseHeaderNames.JKU);
 	}
 
@@ -93,7 +79,7 @@ final class JoseHeader {
 	 * to digitally sign the JWS or encrypt the JWE.
 	 * @return the JSON Web Key
 	 */
-	Map<String, Object> getJwk() {
+	public Map<String, Object> getJwk() {
 		return getHeader(JoseHeaderNames.JWK);
 	}
 
@@ -102,7 +88,7 @@ final class JoseHeader {
 	 * or JWE.
 	 * @return the key ID
 	 */
-	String getKeyId() {
+	public String getKeyId() {
 		return getHeader(JoseHeaderNames.KID);
 	}
 
@@ -112,7 +98,7 @@ final class JoseHeader {
 	 * the JWS or encrypt the JWE.
 	 * @return the X.509 URL
 	 */
-	URL getX509Url() {
+	public URL getX509Url() {
 		return getHeader(JoseHeaderNames.X5U);
 	}
 
@@ -124,7 +110,7 @@ final class JoseHeader {
 	 * {@code List} is a Base64-encoded DER PKIX certificate value.
 	 * @return the X.509 certificate chain
 	 */
-	List<String> getX509CertificateChain() {
+	public List<String> getX509CertificateChain() {
 		return getHeader(JoseHeaderNames.X5C);
 	}
 
@@ -134,7 +120,7 @@ final class JoseHeader {
 	 * corresponding to the key used to digitally sign the JWS or encrypt the JWE.
 	 * @return the X.509 certificate SHA-1 thumbprint
 	 */
-	String getX509SHA1Thumbprint() {
+	public String getX509SHA1Thumbprint() {
 		return getHeader(JoseHeaderNames.X5T);
 	}
 
@@ -144,7 +130,7 @@ final class JoseHeader {
 	 * corresponding to the key used to digitally sign the JWS or encrypt the JWE.
 	 * @return the X.509 certificate SHA-256 thumbprint
 	 */
-	String getX509SHA256Thumbprint() {
+	public String getX509SHA256Thumbprint() {
 		return getHeader(JoseHeaderNames.X5T_S256);
 	}
 
@@ -152,7 +138,7 @@ final class JoseHeader {
 	 * Returns the type header that declares the media type of the JWS/JWE.
 	 * @return the type header
 	 */
-	String getType() {
+	public String getType() {
 		return getHeader(JoseHeaderNames.TYP);
 	}
 
@@ -161,7 +147,7 @@ final class JoseHeader {
 	 * (the payload).
 	 * @return the content type header
 	 */
-	String getContentType() {
+	public String getContentType() {
 		return getHeader(JoseHeaderNames.CTY);
 	}
 
@@ -170,7 +156,7 @@ final class JoseHeader {
 	 * specifications are being used that MUST be understood and processed.
 	 * @return the critical headers
 	 */
-	Set<String> getCritical() {
+	public Set<String> getCritical() {
 		return getHeader(JoseHeaderNames.CRIT);
 	}
 
@@ -178,7 +164,7 @@ final class JoseHeader {
 	 * Returns the headers.
 	 * @return the headers
 	 */
-	Map<String, Object> getHeaders() {
+	public Map<String, Object> getHeaders() {
 		return this.headers;
 	}
 
@@ -189,53 +175,38 @@ final class JoseHeader {
 	 * @return the header value
 	 */
 	@SuppressWarnings("unchecked")
-	<T> T getHeader(String name) {
+	public <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}
+	 * A builder for subclasses of {@link JoseHeader}.
 	 */
-	static Builder from(JoseHeader headers) {
-		return new Builder(headers);
-	}
+	abstract static class AbstractBuilder<T extends JoseHeader, B extends AbstractBuilder<T, B>> {
 
-	/**
-	 * A builder for {@link JoseHeader}.
-	 */
-	static final class Builder {
+		private final Map<String, Object> headers = new HashMap<>();
 
-		final Map<String, Object> headers = new HashMap<>();
+		protected AbstractBuilder() {
+		}
 
-		private Builder(JwaAlgorithm jwaAlgorithm) {
-			algorithm(jwaAlgorithm);
+		protected Map<String, Object> getHeaders() {
+			return this.headers;
 		}
 
-		private Builder(JoseHeader headers) {
-			Assert.notNull(headers, "headers cannot be null");
-			this.headers.putAll(headers.getHeaders());
+		@SuppressWarnings("unchecked")
+		protected final B getThis() {
+			return (B) this; // avoid unchecked casts in subclasses by using "getThis()"
+								// instead of "(B) this"
 		}
 
 		/**
 		 * 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder algorithm(JwaAlgorithm jwaAlgorithm) {
-			Assert.notNull(jwaAlgorithm, "jwaAlgorithm cannot be null");
+		public B algorithm(JwaAlgorithm jwaAlgorithm) {
 			return header(JoseHeaderNames.ALG, jwaAlgorithm);
 		}
 
@@ -244,9 +215,9 @@ final class JoseHeader {
 		 * 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder jwkSetUrl(String jwkSetUrl) {
+		public B jwkSetUrl(String jwkSetUrl) {
 			return header(JoseHeaderNames.JKU, convertAsURL(JoseHeaderNames.JKU, jwkSetUrl));
 		}
 
@@ -254,9 +225,9 @@ final class JoseHeader {
 		 * 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder jwk(Map<String, Object> jwk) {
+		public B jwk(Map<String, Object> jwk) {
 			return header(JoseHeaderNames.JWK, jwk);
 		}
 
@@ -264,9 +235,9 @@ final class JoseHeader {
 		 * 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder keyId(String keyId) {
+		public B keyId(String keyId) {
 			return header(JoseHeaderNames.KID, keyId);
 		}
 
@@ -275,9 +246,9 @@ final class JoseHeader {
 		 * 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder x509Url(String x509Url) {
+		public B x509Url(String x509Url) {
 			return header(JoseHeaderNames.X5U, convertAsURL(JoseHeaderNames.X5U, x509Url));
 		}
 
@@ -288,9 +259,9 @@ final class JoseHeader {
 		 * {@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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder x509CertificateChain(List<String> x509CertificateChain) {
+		public B x509CertificateChain(List<String> x509CertificateChain) {
 			return header(JoseHeaderNames.X5C, x509CertificateChain);
 		}
 
@@ -299,9 +270,9 @@ final class JoseHeader {
 		 * 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder x509SHA1Thumbprint(String x509SHA1Thumbprint) {
+		public B x509SHA1Thumbprint(String x509SHA1Thumbprint) {
 			return header(JoseHeaderNames.X5T, x509SHA1Thumbprint);
 		}
 
@@ -310,18 +281,18 @@ final class JoseHeader {
 		 * 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder x509SHA256Thumbprint(String x509SHA256Thumbprint) {
+		public B 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder type(String type) {
+		public B type(String type) {
 			return header(JoseHeaderNames.TYP, type);
 		}
 
@@ -329,54 +300,56 @@ final class JoseHeader {
 		 * 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder contentType(String contentType) {
+		public B contentType(String contentType) {
 			return header(JoseHeaderNames.CTY, contentType);
 		}
 
 		/**
-		 * Sets the critical headers that indicates which extensions to the JWS/JWE/JWA
+		 * Sets the critical header 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}
+		 * @param name the critical header name
+		 * @param value the critical header value
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder critical(Set<String> headerNames) {
-			return header(JoseHeaderNames.CRIT, headerNames);
+		@SuppressWarnings("unchecked")
+		public B criticalHeader(String name, Object value) {
+			header(name, value);
+			getHeaders().computeIfAbsent(JoseHeaderNames.CRIT, (k) -> new HashSet<String>());
+			((Set<String>) getHeaders().get(JoseHeaderNames.CRIT)).add(name);
+			return getThis();
 		}
 
 		/**
 		 * Sets the header.
 		 * @param name the header name
 		 * @param value the header value
-		 * @return the {@link Builder}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder header(String name, Object value) {
+		public B 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;
+			return getThis();
 		}
 
 		/**
 		 * 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}
+		 * @return the {@link AbstractBuilder}
 		 */
-		Builder headers(Consumer<Map<String, Object>> headersConsumer) {
+		public B headers(Consumer<Map<String, Object>> headersConsumer) {
 			headersConsumer.accept(this.headers);
-			return this;
+			return getThis();
 		}
 
 		/**
 		 * Builds a new {@link JoseHeader}.
 		 * @return a {@link JoseHeader}
 		 */
-		JoseHeader build() {
-			Assert.notEmpty(this.headers, "headers cannot be empty");
-			return new JoseHeader(this.headers);
-		}
+		public abstract T build();
 
 		private static URL convertAsURL(String header, String value) {
 			URL convertedValue = ClaimConversionService.getSharedInstance().convert(value, URL.class);

+ 14 - 30
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeaderNames.java → oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JoseHeaderNames.java

@@ -14,22 +14,7 @@
  * 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
- */
+package org.springframework.security.oauth2.jwt;
 
 /**
  * The Registered Header Parameter Names defined by the JSON Web Token (JWT), JSON Web
@@ -38,8 +23,7 @@ package org.springframework.security.oauth2.client.endpoint;
  *
  * @author Anoop Garlapati
  * @author Joe Grandja
- * @since 5.5
- * @see JoseHeader
+ * @since 5.6
  * @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
@@ -47,53 +31,53 @@ package org.springframework.security.oauth2.client.endpoint;
  * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7516#section-4">JWE JOSE
  * Header</a>
  */
-final class JoseHeaderNames {
+public final class JoseHeaderNames {
 
 	/**
 	 * {@code alg} - the algorithm header identifies the cryptographic algorithm used to
 	 * secure a JWS or JWE
 	 */
-	static final String ALG = "alg";
+	public 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";
+	public 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";
+	public 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";
+	public 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";
+	public 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";
+	public 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";
+	public static final String X5T = "x5t";
 
 	/**
 	 * {@code x5t#S256} - the X.509 certificate SHA-256 thumbprint header is a
@@ -101,25 +85,25 @@ final class JoseHeaderNames {
 	 * X.509 certificate corresponding to the key used to digitally sign a JWS or encrypt
 	 * a JWE
 	 */
-	static final String X5T_S256 = "x5t#S256";
+	public 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";
+	public 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";
+	public 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";
+	public static final String CRIT = "crit";
 
 	private JoseHeaderNames() {
 	}

+ 90 - 0
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwsHeader.java

@@ -0,0 +1,90 @@
+/*
+ * 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.jwt;
+
+import java.util.Map;
+
+import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
+import org.springframework.util.Assert;
+
+/**
+ * The JSON Web Signature (JWS) header is a JSON object representing the header parameters
+ * of a JSON Web Token, that describe the cryptographic operations used to digitally sign
+ * or create a MAC of the contents of the JWS Protected Header and JWS Payload.
+ *
+ * @author Joe Grandja
+ * @since 5.6
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7515#section-4">JWS JOSE
+ * Header</a>
+ */
+public final class JwsHeader extends JoseHeader {
+
+	private JwsHeader(Map<String, Object> headers) {
+		super(headers);
+	}
+
+	@SuppressWarnings("unchecked")
+	@Override
+	public JwsAlgorithm getAlgorithm() {
+		return super.getAlgorithm();
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the provided {@link JwsAlgorithm}.
+	 * @param jwsAlgorithm the {@link JwsAlgorithm}
+	 * @return the {@link Builder}
+	 */
+	public static Builder with(JwsAlgorithm jwsAlgorithm) {
+		return new Builder(jwsAlgorithm);
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the provided {@code headers}.
+	 * @param headers the headers
+	 * @return the {@link Builder}
+	 */
+	public static Builder from(JwsHeader headers) {
+		return new Builder(headers);
+	}
+
+	/**
+	 * A builder for {@link JwsHeader}.
+	 */
+	public static final class Builder extends AbstractBuilder<JwsHeader, Builder> {
+
+		private Builder(JwsAlgorithm jwsAlgorithm) {
+			Assert.notNull(jwsAlgorithm, "jwsAlgorithm cannot be null");
+			algorithm(jwsAlgorithm);
+		}
+
+		private Builder(JwsHeader headers) {
+			Assert.notNull(headers, "headers cannot be null");
+			getHeaders().putAll(headers.getHeaders());
+		}
+
+		/**
+		 * Builds a new {@link JwsHeader}.
+		 * @return a {@link JwsHeader}
+		 */
+		@Override
+		public JwsHeader build() {
+			return new JwsHeader(getHeaders());
+		}
+
+	}
+
+}

+ 17 - 35
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSet.java → oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimsSet.java

@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.springframework.security.oauth2.client.endpoint;
+package org.springframework.security.oauth2.jwt;
 
 import java.net.URL;
 import java.time.Instant;
@@ -25,39 +25,21 @@ 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
+ * @since 5.6
  * @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 {
+public final class JwtClaimsSet implements JwtClaimAccessor {
 
 	private final Map<String, Object> claims;
 
@@ -74,7 +56,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 	 * Returns a new {@link Builder}.
 	 * @return the {@link Builder}
 	 */
-	static Builder builder() {
+	public static Builder builder() {
 		return new Builder();
 	}
 
@@ -83,16 +65,16 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 	 * @param claims a JWT claims set
 	 * @return the {@link Builder}
 	 */
-	static Builder from(JwtClaimsSet claims) {
+	public static Builder from(JwtClaimsSet claims) {
 		return new Builder(claims);
 	}
 
 	/**
 	 * A builder for {@link JwtClaimsSet}.
 	 */
-	static final class Builder {
+	public static final class Builder {
 
-		final Map<String, Object> claims = new HashMap<>();
+		private final Map<String, Object> claims = new HashMap<>();
 
 		private Builder() {
 		}
@@ -108,7 +90,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * @param issuer the issuer identifier
 		 * @return the {@link Builder}
 		 */
-		Builder issuer(String issuer) {
+		public Builder issuer(String issuer) {
 			return claim(JwtClaimNames.ISS, issuer);
 		}
 
@@ -118,7 +100,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * @param subject the subject identifier
 		 * @return the {@link Builder}
 		 */
-		Builder subject(String subject) {
+		public Builder subject(String subject) {
 			return claim(JwtClaimNames.SUB, subject);
 		}
 
@@ -128,7 +110,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * @param audience the audience that this JWT is intended for
 		 * @return the {@link Builder}
 		 */
-		Builder audience(List<String> audience) {
+		public Builder audience(List<String> audience) {
 			return claim(JwtClaimNames.AUD, audience);
 		}
 
@@ -139,7 +121,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * processing
 		 * @return the {@link Builder}
 		 */
-		Builder expiresAt(Instant expiresAt) {
+		public Builder expiresAt(Instant expiresAt) {
 			return claim(JwtClaimNames.EXP, expiresAt);
 		}
 
@@ -150,7 +132,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * processing
 		 * @return the {@link Builder}
 		 */
-		Builder notBefore(Instant notBefore) {
+		public Builder notBefore(Instant notBefore) {
 			return claim(JwtClaimNames.NBF, notBefore);
 		}
 
@@ -160,7 +142,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * @param issuedAt the time at which the JWT was issued
 		 * @return the {@link Builder}
 		 */
-		Builder issuedAt(Instant issuedAt) {
+		public Builder issuedAt(Instant issuedAt) {
 			return claim(JwtClaimNames.IAT, issuedAt);
 		}
 
@@ -170,7 +152,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * @param jti the unique identifier for the JWT
 		 * @return the {@link Builder}
 		 */
-		Builder id(String jti) {
+		public Builder id(String jti) {
 			return claim(JwtClaimNames.JTI, jti);
 		}
 
@@ -180,7 +162,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * @param value the claim value
 		 * @return the {@link Builder}
 		 */
-		Builder claim(String name, Object value) {
+		public 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);
@@ -192,7 +174,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * add, replace, or remove.
 		 * @param claimsConsumer a {@code Consumer} of the claims
 		 */
-		Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
+		public Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
 			claimsConsumer.accept(this.claims);
 			return this;
 		}
@@ -201,7 +183,7 @@ final class JwtClaimsSet implements JwtClaimAccessor {
 		 * Builds a new {@link JwtClaimsSet}.
 		 * @return a {@link JwtClaimsSet}
 		 */
-		JwtClaimsSet build() {
+		public JwtClaimsSet build() {
 			Assert.notEmpty(this.claims, "claims cannot be empty");
 
 			// The value of the 'iss' claim is a String or URL (StringOrURI).

+ 57 - 0
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoder.java

@@ -0,0 +1,57 @@
+/*
+ * 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.jwt;
+
+/**
+ * Implementations of this interface are responsible for encoding a JSON Web Token (JWT)
+ * to it's compact claims representation format.
+ *
+ * <p>
+ * JWTs may be represented using the JWS Compact Serialization format for a JSON Web
+ * Signature (JWS) structure or JWE Compact Serialization format for a JSON Web Encryption
+ * (JWE) structure. Therefore, implementors are responsible for signing a JWS and/or
+ * encrypting a JWE.
+ *
+ * @author Anoop Garlapati
+ * @author Joe Grandja
+ * @since 5.6
+ * @see Jwt
+ * @see JwtEncoderParameters
+ * @see JwtDecoder
+ * @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/rfc7516">JSON Web Encryption
+ * (JWE)</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://tools.ietf.org/html/rfc7516#section-3.1">JWE
+ * Compact Serialization</a>
+ */
+@FunctionalInterface
+public interface JwtEncoder {
+
+	/**
+	 * Encode the JWT to it's compact claims representation format.
+	 * @param parameters the parameters containing the JOSE header and JWT Claims Set
+	 * @return a {@link Jwt}
+	 * @throws JwtEncodingException if an error occurs while attempting to encode the JWT
+	 */
+	Jwt encode(JwtEncoderParameters parameters) throws JwtEncodingException;
+
+}

+ 83 - 0
oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoderParameters.java

@@ -0,0 +1,83 @@
+/*
+ * 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.jwt;
+
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+
+/**
+ * A holder of parameters containing the JWS headers and JWT Claims Set.
+ *
+ * @author Joe Grandja
+ * @since 5.6
+ * @see JwsHeader
+ * @see JwtClaimsSet
+ * @see JwtEncoder
+ */
+public final class JwtEncoderParameters {
+
+	private final JwsHeader jwsHeader;
+
+	private final JwtClaimsSet claims;
+
+	private JwtEncoderParameters(JwsHeader jwsHeader, JwtClaimsSet claims) {
+		this.jwsHeader = jwsHeader;
+		this.claims = claims;
+	}
+
+	/**
+	 * Returns a new {@link JwtEncoderParameters}, initialized with the provided
+	 * {@link JwtClaimsSet}.
+	 * @param claims the {@link JwtClaimsSet}
+	 * @return the {@link JwtEncoderParameters}
+	 */
+	public static JwtEncoderParameters from(JwtClaimsSet claims) {
+		Assert.notNull(claims, "claims cannot be null");
+		return new JwtEncoderParameters(null, claims);
+	}
+
+	/**
+	 * Returns a new {@link JwtEncoderParameters}, initialized with the provided
+	 * {@link JwsHeader} and {@link JwtClaimsSet}.
+	 * @param jwsHeader the {@link JwsHeader}
+	 * @param claims the {@link JwtClaimsSet}
+	 * @return the {@link JwtEncoderParameters}
+	 */
+	public static JwtEncoderParameters from(JwsHeader jwsHeader, JwtClaimsSet claims) {
+		Assert.notNull(jwsHeader, "jwsHeader cannot be null");
+		Assert.notNull(claims, "claims cannot be null");
+		return new JwtEncoderParameters(jwsHeader, claims);
+	}
+
+	/**
+	 * Returns the {@link JwsHeader JWS headers}.
+	 * @return the {@link JwsHeader}, or {@code null} if not specified
+	 */
+	@Nullable
+	public JwsHeader getJwsHeader() {
+		return this.jwsHeader;
+	}
+
+	/**
+	 * Returns the {@link JwtClaimsSet claims}.
+	 * @return the {@link JwtClaimsSet}
+	 */
+	public JwtClaimsSet getClaims() {
+		return this.claims;
+	}
+
+}

+ 5 - 22
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtEncodingException.java → oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java

@@ -14,39 +14,22 @@
  * 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
- */
+package org.springframework.security.oauth2.jwt;
 
 /**
  * This exception is thrown when an error occurs while attempting to encode a JSON Web
  * Token (JWT).
  *
  * @author Joe Grandja
- * @since 5.5
+ * @since 5.6
  */
-class JwtEncodingException extends JwtException {
+public class JwtEncodingException extends JwtException {
 
 	/**
 	 * Constructs a {@code JwtEncodingException} using the provided parameters.
 	 * @param message the detail message
 	 */
-	JwtEncodingException(String message) {
+	public JwtEncodingException(String message) {
 		super(message);
 	}
 
@@ -55,7 +38,7 @@ class JwtEncodingException extends JwtException {
 	 * @param message the detail message
 	 * @param cause the root cause
 	 */
-	JwtEncodingException(String message, Throwable cause) {
+	public JwtEncodingException(String message, Throwable cause) {
 		super(message, cause);
 	}
 

+ 29 - 36
oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoder.java → oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.java

@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.springframework.security.oauth2.client.endpoint;
+package org.springframework.security.oauth2.jwt;
 
 import java.net.URI;
 import java.net.URL;
@@ -46,38 +46,23 @@ 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.security.oauth2.jose.jws.SignatureAlgorithm;
 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.
+ * An implementation of a {@link JwtEncoder} 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
+ * @since 5.6
+ * @see JwtEncoder
  * @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
@@ -89,10 +74,12 @@ import org.springframework.util.StringUtils;
  * @see <a target="_blank" href="https://connect2id.com/products/nimbus-jose-jwt">Nimbus
  * JOSE + JWT SDK</a>
  */
-final class NimbusJwsEncoder {
+public final class NimbusJwtEncoder implements JwtEncoder {
 
 	private static final String ENCODING_ERROR_MESSAGE_TEMPLATE = "An error occurred while attempting to encode the Jwt: %s";
 
+	private static final JwsHeader DEFAULT_JWS_HEADER = JwsHeader.with(SignatureAlgorithm.RS256).build();
+
 	private static final JWSSignerFactory JWS_SIGNER_FACTORY = new DefaultJWSSignerFactory();
 
 	private final Map<JWK, JWSSigner> jwsSigners = new ConcurrentHashMap<>();
@@ -100,17 +87,23 @@ final class NimbusJwsEncoder {
 	private final JWKSource<SecurityContext> jwkSource;
 
 	/**
-	 * Constructs a {@code NimbusJwsEncoder} using the provided parameters.
+	 * Constructs a {@code NimbusJwtEncoder} using the provided parameters.
 	 * @param jwkSource the {@code com.nimbusds.jose.jwk.source.JWKSource}
 	 */
-	NimbusJwsEncoder(JWKSource<SecurityContext> jwkSource) {
+	public NimbusJwtEncoder(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");
+	@Override
+	public Jwt encode(JwtEncoderParameters parameters) throws JwtEncodingException {
+		Assert.notNull(parameters, "parameters cannot be null");
+
+		JwsHeader headers = parameters.getJwsHeader();
+		if (headers == null) {
+			headers = DEFAULT_JWS_HEADER;
+		}
+		JwtClaimsSet claims = parameters.getClaims();
 
 		JWK jwk = selectJwk(headers);
 		headers = addKeyIdentifierHeadersIfNecessary(headers, jwk);
@@ -120,7 +113,7 @@ final class NimbusJwsEncoder {
 		return new Jwt(jws, claims.getIssuedAt(), claims.getExpiresAt(), headers.getHeaders(), claims.getClaims());
 	}
 
-	private JWK selectJwk(JoseHeader headers) {
+	private JWK selectJwk(JwsHeader headers) {
 		List<JWK> jwks;
 		try {
 			JWKSelector jwkSelector = new JWKSelector(createJwkMatcher(headers));
@@ -144,11 +137,11 @@ final class NimbusJwsEncoder {
 		return jwks.get(0);
 	}
 
-	private String serialize(JoseHeader headers, JwtClaimsSet claims, JWK jwk) {
+	private String serialize(JwsHeader headers, JwtClaimsSet claims, JWK jwk) {
 		JWSHeader jwsHeader = convert(headers);
 		JWTClaimsSet jwtClaimsSet = convert(claims);
 
-		JWSSigner jwsSigner = this.jwsSigners.computeIfAbsent(jwk, NimbusJwsEncoder::createSigner);
+		JWSSigner jwsSigner = this.jwsSigners.computeIfAbsent(jwk, NimbusJwtEncoder::createSigner);
 
 		SignedJWT signedJwt = new SignedJWT(jwsHeader, jwtClaimsSet);
 		try {
@@ -161,7 +154,7 @@ final class NimbusJwsEncoder {
 		return signedJwt.serialize();
 	}
 
-	private static JWKMatcher createJwkMatcher(JoseHeader headers) {
+	private static JWKMatcher createJwkMatcher(JwsHeader headers) {
 		JWSAlgorithm jwsAlgorithm = JWSAlgorithm.parse(headers.getAlgorithm().getName());
 
 		if (JWSAlgorithm.Family.RSA.contains(jwsAlgorithm) || JWSAlgorithm.Family.EC.contains(jwsAlgorithm)) {
@@ -189,7 +182,7 @@ final class NimbusJwsEncoder {
 		return null;
 	}
 
-	private static JoseHeader addKeyIdentifierHeadersIfNecessary(JoseHeader headers, JWK jwk) {
+	private static JwsHeader addKeyIdentifierHeadersIfNecessary(JwsHeader headers, JWK jwk) {
 		// Check if headers have already been added
 		if (StringUtils.hasText(headers.getKeyId()) && StringUtils.hasText(headers.getX509SHA256Thumbprint())) {
 			return headers;
@@ -199,7 +192,7 @@ final class NimbusJwsEncoder {
 			return headers;
 		}
 
-		JoseHeader.Builder headersBuilder = JoseHeader.from(headers);
+		JwsHeader.Builder headersBuilder = JwsHeader.from(headers);
 		if (!StringUtils.hasText(headers.getKeyId()) && StringUtils.hasText(jwk.getKeyID())) {
 			headersBuilder.keyId(jwk.getKeyID());
 		}
@@ -220,7 +213,7 @@ final class NimbusJwsEncoder {
 		}
 	}
 
-	private static JWSHeader convert(JoseHeader headers) {
+	private static JWSHeader convert(JwsHeader headers) {
 		JWSHeader.Builder builder = new JWSHeader.Builder(JWSAlgorithm.parse(headers.getAlgorithm().getName()));
 
 		if (headers.getJwkSetUrl() != null) {

+ 111 - 0
oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwsHeaderTests.java

@@ -0,0 +1,111 @@
+/*
+ * 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.jwt;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Tests for {@link JwsHeader}.
+ *
+ * @author Joe Grandja
+ */
+public class JwsHeaderTests {
+
+	@Test
+	public void withWhenNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JwsHeader.with(null))
+				.withMessage("jwsAlgorithm cannot be null");
+	}
+
+	@Test
+	public void fromWhenNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JwsHeader.from(null))
+				.withMessage("headers cannot be null");
+	}
+
+	@Test
+	public void fromWhenHeadersProvidedThenCopied() {
+		JwsHeader expectedJwsHeader = TestJwsHeaders.jwsHeader().build();
+		JwsHeader jwsHeader = JwsHeader.from(expectedJwsHeader).build();
+		assertThat(jwsHeader.getHeaders()).isEqualTo(expectedJwsHeader.getHeaders());
+	}
+
+	@Test
+	public void buildWhenAllHeadersProvidedThenAllHeadersAreSet() {
+		JwsHeader expectedJwsHeader = TestJwsHeaders.jwsHeader().build();
+
+		// @formatter:off
+		JwsHeader jwsHeader = JwsHeader.with(expectedJwsHeader.getAlgorithm())
+				.jwkSetUrl(expectedJwsHeader.getJwkSetUrl().toExternalForm())
+				.jwk(expectedJwsHeader.getJwk())
+				.keyId(expectedJwsHeader.getKeyId())
+				.x509Url(expectedJwsHeader.getX509Url().toExternalForm())
+				.x509CertificateChain(expectedJwsHeader.getX509CertificateChain())
+				.x509SHA1Thumbprint(expectedJwsHeader.getX509SHA1Thumbprint())
+				.x509SHA256Thumbprint(expectedJwsHeader.getX509SHA256Thumbprint())
+				.type(expectedJwsHeader.getType())
+				.contentType(expectedJwsHeader.getContentType())
+				.criticalHeader("critical-header1-name", "critical-header1-value")
+				.criticalHeader("critical-header2-name", "critical-header2-value")
+				.headers((headers) -> headers.put("custom-header-name", "custom-header-value"))
+				.build();
+		// @formatter:on
+
+		assertThat(jwsHeader.getAlgorithm()).isEqualTo(expectedJwsHeader.getAlgorithm());
+		assertThat(jwsHeader.getJwkSetUrl()).isEqualTo(expectedJwsHeader.getJwkSetUrl());
+		assertThat(jwsHeader.getJwk()).isEqualTo(expectedJwsHeader.getJwk());
+		assertThat(jwsHeader.getKeyId()).isEqualTo(expectedJwsHeader.getKeyId());
+		assertThat(jwsHeader.getX509Url()).isEqualTo(expectedJwsHeader.getX509Url());
+		assertThat(jwsHeader.getX509CertificateChain()).isEqualTo(expectedJwsHeader.getX509CertificateChain());
+		assertThat(jwsHeader.getX509SHA1Thumbprint()).isEqualTo(expectedJwsHeader.getX509SHA1Thumbprint());
+		assertThat(jwsHeader.getX509SHA256Thumbprint()).isEqualTo(expectedJwsHeader.getX509SHA256Thumbprint());
+		assertThat(jwsHeader.getType()).isEqualTo(expectedJwsHeader.getType());
+		assertThat(jwsHeader.getContentType()).isEqualTo(expectedJwsHeader.getContentType());
+		assertThat(jwsHeader.getCritical()).containsExactlyInAnyOrder("critical-header1-name", "critical-header2-name");
+		assertThat(jwsHeader.<String>getHeader("critical-header1-name")).isEqualTo("critical-header1-value");
+		assertThat(jwsHeader.<String>getHeader("critical-header2-name")).isEqualTo("critical-header2-value");
+		assertThat(jwsHeader.<String>getHeader("custom-header-name")).isEqualTo("custom-header-value");
+	}
+
+	@Test
+	public void headerWhenNameNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> JwsHeader.with(SignatureAlgorithm.RS256).header(null, "value"))
+				.withMessage("name cannot be empty");
+	}
+
+	@Test
+	public void headerWhenValueNullThenThrowIllegalArgumentException() {
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> JwsHeader.with(SignatureAlgorithm.RS256).header("name", null))
+				.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void getHeaderWhenNullThenThrowIllegalArgumentException() {
+		JwsHeader jwsHeader = TestJwsHeaders.jwsHeader().build();
+
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> jwsHeader.getHeader(null))
+				.withMessage("name cannot be empty");
+	}
+
+}

+ 3 - 18
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSetTests.java → oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtClaimsSetTests.java

@@ -14,28 +14,13 @@
  * limitations under the License.
  */
 
-package org.springframework.security.oauth2.client.endpoint;
+package org.springframework.security.oauth2.jwt;
 
 import org.junit.jupiter.api.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}.
  *
@@ -46,7 +31,7 @@ public class JwtClaimsSetTests {
 	@Test
 	public void buildWhenClaimsEmptyThenThrowIllegalArgumentException() {
 		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JwtClaimsSet.builder().build())
-				.isInstanceOf(IllegalArgumentException.class).withMessage("claims cannot be empty");
+				.withMessage("claims cannot be empty");
 	}
 
 	@Test
@@ -80,7 +65,7 @@ public class JwtClaimsSetTests {
 	@Test
 	public void fromWhenNullThenThrowIllegalArgumentException() {
 		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JwtClaimsSet.from(null))
-				.isInstanceOf(IllegalArgumentException.class).withMessage("claims cannot be null");
+				.withMessage("claims cannot be null");
 	}
 
 	@Test

+ 518 - 0
oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJweEncoderTests.java

@@ -0,0 +1,518 @@
+/*
+ * 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.jwt;
+
+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.function.Consumer;
+import java.util.stream.Collectors;
+
+import com.nimbusds.jose.EncryptionMethod;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWEAlgorithm;
+import com.nimbusds.jose.JWEHeader;
+import com.nimbusds.jose.JWEObject;
+import com.nimbusds.jose.Payload;
+import com.nimbusds.jose.crypto.RSAEncrypter;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKMatcher;
+import com.nimbusds.jose.jwk.JWKSelector;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.KeyType;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jose.util.Base64;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.JWTClaimsSet;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.security.oauth2.jose.JwaAlgorithm;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for proofing out future support of JWE.
+ *
+ * @author Joe Grandja
+ */
+public class NimbusJweEncoderTests {
+
+	// @formatter:off
+	private static final JweHeader DEFAULT_JWE_HEADER =
+			JweHeader.with(JweAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM.getName()).build();
+	// @formatter:on
+
+	private List<JWK> jwkList;
+
+	private JWKSource<SecurityContext> jwkSource;
+
+	private NimbusJweEncoder jweEncoder;
+
+	@BeforeEach
+	public void setUp() {
+		this.jwkList = new ArrayList<>();
+		this.jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(new JWKSet(this.jwkList));
+		this.jweEncoder = new NimbusJweEncoder(this.jwkSource);
+	}
+
+	@Test
+	public void encodeWhenJwtClaimsSetThenEncodes() {
+		RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK;
+		this.jwkList.add(rsaJwk);
+
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		// @formatter:off
+		// **********************
+		// Assume future API:
+		// 		JwtEncoderParameters.with(JweHeader jweHeader, JwtClaimsSet claims)
+		// **********************
+		// @formatter:on
+		Jwt encodedJwe = this.jweEncoder.encode(JwtEncoderParameters.from(jwtClaimsSet));
+
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.ALG)).isEqualTo(DEFAULT_JWE_HEADER.getAlgorithm());
+		assertThat(encodedJwe.getHeaders().get("enc")).isEqualTo(DEFAULT_JWE_HEADER.<String>getHeader("enc"));
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.JKU)).isNull();
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.JWK)).isNull();
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(rsaJwk.getKeyID());
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.X5U)).isNull();
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.X5C)).isNull();
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.X5T)).isNull();
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.X5T_S256)).isNull();
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.TYP)).isNull();
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.CTY)).isNull();
+		assertThat(encodedJwe.getHeaders().get(JoseHeaderNames.CRIT)).isNull();
+
+		assertThat(encodedJwe.getIssuer()).isEqualTo(jwtClaimsSet.getIssuer());
+		assertThat(encodedJwe.getSubject()).isEqualTo(jwtClaimsSet.getSubject());
+		assertThat(encodedJwe.getAudience()).isEqualTo(jwtClaimsSet.getAudience());
+		assertThat(encodedJwe.getExpiresAt()).isEqualTo(jwtClaimsSet.getExpiresAt());
+		assertThat(encodedJwe.getNotBefore()).isEqualTo(jwtClaimsSet.getNotBefore());
+		assertThat(encodedJwe.getIssuedAt()).isEqualTo(jwtClaimsSet.getIssuedAt());
+		assertThat(encodedJwe.getId()).isEqualTo(jwtClaimsSet.getId());
+		assertThat(encodedJwe.<String>getClaim("custom-claim-name")).isEqualTo("custom-claim-value");
+
+		assertThat(encodedJwe.getTokenValue()).isNotNull();
+	}
+
+	@Test
+	public void encodeWhenNestedJwsThenEncodes() {
+		// See Nimbus example -> Nested signed and encrypted JWT
+		// https://connect2id.com/products/nimbus-jose-jwt/examples/signed-and-encrypted-jwt
+
+		RSAKey rsaJwk = TestJwks.DEFAULT_RSA_JWK;
+		this.jwkList.add(rsaJwk);
+
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		// @formatter:off
+		// **********************
+		// Assume future API:
+		// 		JwtEncoderParameters.with(JwsHeader jwsHeader, JweHeader jweHeader, JwtClaimsSet claims)
+		// **********************
+		// @formatter:on
+		Jwt encodedJweNestedJws = this.jweEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
+
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.ALG))
+				.isEqualTo(DEFAULT_JWE_HEADER.getAlgorithm());
+		assertThat(encodedJweNestedJws.getHeaders().get("enc")).isEqualTo(DEFAULT_JWE_HEADER.<String>getHeader("enc"));
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.JKU)).isNull();
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.JWK)).isNull();
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(rsaJwk.getKeyID());
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.X5U)).isNull();
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.X5C)).isNull();
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.X5T)).isNull();
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.X5T_S256)).isNull();
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.TYP)).isNull();
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.CTY)).isEqualTo("JWT");
+		assertThat(encodedJweNestedJws.getHeaders().get(JoseHeaderNames.CRIT)).isNull();
+
+		assertThat(encodedJweNestedJws.getIssuer()).isEqualTo(jwtClaimsSet.getIssuer());
+		assertThat(encodedJweNestedJws.getSubject()).isEqualTo(jwtClaimsSet.getSubject());
+		assertThat(encodedJweNestedJws.getAudience()).isEqualTo(jwtClaimsSet.getAudience());
+		assertThat(encodedJweNestedJws.getExpiresAt()).isEqualTo(jwtClaimsSet.getExpiresAt());
+		assertThat(encodedJweNestedJws.getNotBefore()).isEqualTo(jwtClaimsSet.getNotBefore());
+		assertThat(encodedJweNestedJws.getIssuedAt()).isEqualTo(jwtClaimsSet.getIssuedAt());
+		assertThat(encodedJweNestedJws.getId()).isEqualTo(jwtClaimsSet.getId());
+		assertThat(encodedJweNestedJws.<String>getClaim("custom-claim-name")).isEqualTo("custom-claim-value");
+
+		assertThat(encodedJweNestedJws.getTokenValue()).isNotNull();
+	}
+
+	enum JweAlgorithm implements JwaAlgorithm {
+
+		RSA_OAEP_256("RSA-OAEP-256");
+
+		private final String name;
+
+		JweAlgorithm(String name) {
+			this.name = name;
+		}
+
+		@Override
+		public String getName() {
+			return this.name;
+		}
+
+	}
+
+	private static final class JweHeader extends JoseHeader {
+
+		private JweHeader(Map<String, Object> headers) {
+			super(headers);
+		}
+
+		@SuppressWarnings("unchecked")
+		@Override
+		public JweAlgorithm getAlgorithm() {
+			return super.getAlgorithm();
+		}
+
+		private static Builder with(JweAlgorithm jweAlgorithm, String enc) {
+			return new Builder(jweAlgorithm, enc);
+		}
+
+		private static Builder from(JweHeader headers) {
+			return new Builder(headers);
+		}
+
+		private static final class Builder extends AbstractBuilder<JweHeader, Builder> {
+
+			private Builder(JweAlgorithm jweAlgorithm, String enc) {
+				Assert.notNull(jweAlgorithm, "jweAlgorithm cannot be null");
+				Assert.hasText(enc, "enc cannot be empty");
+				algorithm(jweAlgorithm);
+				header("enc", enc);
+			}
+
+			private Builder(JweHeader headers) {
+				Assert.notNull(headers, "headers cannot be null");
+				Consumer<Map<String, Object>> headersConsumer = (h) -> h.putAll(headers.getHeaders());
+				headers(headersConsumer);
+			}
+
+			@Override
+			public JweHeader build() {
+				return new JweHeader(getHeaders());
+			}
+
+		}
+
+	}
+
+	private static final class NimbusJweEncoder implements JwtEncoder {
+
+		private static final String ENCODING_ERROR_MESSAGE_TEMPLATE = "An error occurred while attempting to encode the Jwt: %s";
+
+		private static final Converter<JweHeader, JWEHeader> JWE_HEADER_CONVERTER = new JweHeaderConverter();
+
+		private static final Converter<JwtClaimsSet, JWTClaimsSet> JWT_CLAIMS_SET_CONVERTER = new JwtClaimsSetConverter();
+
+		private final JWKSource<SecurityContext> jwkSource;
+
+		private final JwtEncoder jwsEncoder;
+
+		private NimbusJweEncoder(JWKSource<SecurityContext> jwkSource) {
+			Assert.notNull(jwkSource, "jwkSource cannot be null");
+			this.jwkSource = jwkSource;
+			this.jwsEncoder = new NimbusJwtEncoder(jwkSource);
+		}
+
+		@Override
+		public Jwt encode(JwtEncoderParameters parameters) throws JwtEncodingException {
+			Assert.notNull(parameters, "parameters cannot be null");
+
+			// @formatter:off
+			// **********************
+			// Assume future API:
+			// 		JwtEncoderParameters.getJweHeader()
+			// **********************
+			// @formatter:on
+			JweHeader jweHeader = DEFAULT_JWE_HEADER; // Assume this is accessed via
+														// JwtEncoderParameters.getJweHeader()
+
+			JwsHeader jwsHeader = parameters.getJwsHeader();
+			JwtClaimsSet claims = parameters.getClaims();
+
+			JWK jwk = selectJwk(jweHeader);
+			jweHeader = addKeyIdentifierHeadersIfNecessary(jweHeader, jwk);
+
+			JWEHeader jweHeader2 = JWE_HEADER_CONVERTER.convert(jweHeader);
+			JWTClaimsSet jwtClaimsSet = JWT_CLAIMS_SET_CONVERTER.convert(claims);
+
+			String payload;
+			if (jwsHeader != null) {
+				// Sign then encrypt
+				Jwt jws = this.jwsEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+				payload = jws.getTokenValue();
+
+				// @formatter:off
+				jweHeader = JweHeader.from(jweHeader)
+						.contentType("JWT")		// Indicates Nested JWT (REQUIRED)
+						.build();
+				// @formatter:on
+			}
+			else {
+				// Encrypt only
+				payload = jwtClaimsSet.toString();
+			}
+
+			JWEObject jweObject = new JWEObject(jweHeader2, new Payload(payload));
+			try {
+				// FIXME
+				// Resolve type of JWEEncrypter using the JWK key type
+				// For now, assuming RSA key type
+				jweObject.encrypt(new RSAEncrypter(jwk.toRSAKey()));
+			}
+			catch (JOSEException ex) {
+				throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE,
+						"Failed to encrypt the JWT -> " + ex.getMessage()), ex);
+			}
+			String jwe = jweObject.serialize();
+
+			// NOTE:
+			// For the Nested JWS use case, we lose access to the JWS Header in the
+			// returned JWT.
+			// If this is needed, we can simply add the new method Jwt.getNestedHeaders().
+			return new Jwt(jwe, claims.getIssuedAt(), claims.getExpiresAt(), jweHeader.getHeaders(),
+					claims.getClaims());
+		}
+
+		private JWK selectJwk(JweHeader 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 encryption key -> " + ex.getMessage()), ex);
+			}
+
+			if (jwks.size() > 1) {
+				throw new JwtEncodingException(String.format(ENCODING_ERROR_MESSAGE_TEMPLATE,
+						"Found multiple JWK encryption keys for algorithm '" + headers.getAlgorithm().getName() + "'"));
+			}
+
+			if (jwks.isEmpty()) {
+				throw new JwtEncodingException(
+						String.format(ENCODING_ERROR_MESSAGE_TEMPLATE, "Failed to select a JWK encryption key"));
+			}
+
+			return jwks.get(0);
+		}
+
+		private static JWKMatcher createJwkMatcher(JweHeader headers) {
+			JWEAlgorithm jweAlgorithm = JWEAlgorithm.parse(headers.getAlgorithm().getName());
+
+			// @formatter:off
+			return new JWKMatcher.Builder()
+					.keyType(KeyType.forAlgorithm(jweAlgorithm))
+					.keyID(headers.getKeyId())
+					.keyUses(KeyUse.ENCRYPTION, null)
+					.algorithms(jweAlgorithm, null)
+					.x509CertSHA256Thumbprint(Base64URL.from(headers.getX509SHA256Thumbprint()))
+					.build();
+			// @formatter:on
+		}
+
+		private static JweHeader addKeyIdentifierHeadersIfNecessary(JweHeader 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;
+			}
+
+			JweHeader.Builder headersBuilder = JweHeader.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 class JweHeaderConverter implements Converter<JweHeader, JWEHeader> {
+
+		@Override
+		public JWEHeader convert(JweHeader headers) {
+			JWEAlgorithm jweAlgorithm = JWEAlgorithm.parse(headers.getAlgorithm().getName());
+			EncryptionMethod encryptionMethod = EncryptionMethod.parse(headers.getHeader("enc"));
+			JWEHeader.Builder builder = new JWEHeader.Builder(jweAlgorithm, encryptionMethod);
+
+			URL jwkSetUri = headers.getJwkSetUrl();
+			if (jwkSetUri != null) {
+				try {
+					builder.jwkURL(jwkSetUri.toURI());
+				}
+				catch (Exception ex) {
+					throw new IllegalArgumentException(
+							"Unable to convert '" + JoseHeaderNames.JKU + "' JOSE header to a URI", ex);
+				}
+			}
+
+			Map<String, Object> jwk = headers.getJwk();
+			if (!CollectionUtils.isEmpty(jwk)) {
+				try {
+					builder.jwk(JWK.parse(jwk));
+				}
+				catch (Exception ex) {
+					throw new IllegalArgumentException("Unable to convert '" + JoseHeaderNames.JWK + "' JOSE header",
+							ex);
+				}
+			}
+
+			String keyId = headers.getKeyId();
+			if (StringUtils.hasText(keyId)) {
+				builder.keyID(keyId);
+			}
+
+			URL x509Uri = headers.getX509Url();
+			if (x509Uri != null) {
+				try {
+					builder.x509CertURL(x509Uri.toURI());
+				}
+				catch (Exception ex) {
+					throw new IllegalArgumentException(
+							"Unable to convert '" + JoseHeaderNames.X5U + "' JOSE header to a URI", ex);
+				}
+			}
+
+			List<String> x509CertificateChain = headers.getX509CertificateChain();
+			if (!CollectionUtils.isEmpty(x509CertificateChain)) {
+				builder.x509CertChain(x509CertificateChain.stream().map(Base64::new).collect(Collectors.toList()));
+			}
+
+			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 = headers.getHeaders().entrySet().stream()
+					.filter((header) -> !JWEHeader.getRegisteredParameterNames().contains(header.getKey()))
+					.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+			if (!CollectionUtils.isEmpty(customHeaders)) {
+				builder.customParams(customHeaders);
+			}
+
+			return builder.build();
+		}
+
+	}
+
+	private static class JwtClaimsSetConverter implements Converter<JwtClaimsSet, JWTClaimsSet> {
+
+		@Override
+		public 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();
+		}
+
+	}
+
+}

+ 50 - 51
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoderTests.java → oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoderTests.java

@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.springframework.security.oauth2.client.endpoint;
+package org.springframework.security.oauth2.jwt;
 
 import java.security.interfaces.ECPrivateKey;
 import java.security.interfaces.ECPublicKey;
@@ -42,8 +42,6 @@ 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;
@@ -54,74 +52,58 @@ 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}.
+ * Tests for {@link NimbusJwtEncoder}.
  *
  * @author Joe Grandja
  */
-public class NimbusJwsEncoderTests {
+public class NimbusJwtEncoderTests {
 
 	private List<JWK> jwkList;
 
 	private JWKSource<SecurityContext> jwkSource;
 
-	private NimbusJwsEncoder jwsEncoder;
+	private NimbusJwtEncoder jwtEncoder;
 
 	@BeforeEach
 	public void setUp() {
 		this.jwkList = new ArrayList<>();
 		this.jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(new JWKSet(this.jwkList));
-		this.jwsEncoder = new NimbusJwsEncoder(this.jwkSource);
+		this.jwtEncoder = new NimbusJwtEncoder(this.jwkSource);
 	}
 
 	@Test
 	public void constructorWhenJwkSourceNullThenThrowIllegalArgumentException() {
-		assertThatIllegalArgumentException().isThrownBy(() -> new NimbusJwsEncoder(null))
+		assertThatIllegalArgumentException().isThrownBy(() -> new NimbusJwtEncoder(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");
+	public void encodeWhenParametersNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.jwtEncoder.encode(null))
+				.withMessage("parameters cannot be null");
 	}
 
 	@Test
 	public void encodeWhenClaimsNullThenThrowIllegalArgumentException() {
-		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build();
 
-		assertThatIllegalArgumentException().isThrownBy(() -> this.jwsEncoder.encode(joseHeader, null))
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, null)))
 				.withMessage("claims cannot be null");
 	}
 
 	@Test
 	public void encodeWhenJwkSelectFailedThenThrowJwtEncodingException() throws Exception {
 		this.jwkSource = mock(JWKSource.class);
-		this.jwsEncoder = new NimbusJwsEncoder(this.jwkSource);
+		this.jwtEncoder = new NimbusJwtEncoder(this.jwkSource);
 		given(this.jwkSource.get(any(), any())).willThrow(new KeySourceException("key source error"));
 
-		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build();
 		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
 
 		assertThatExceptionOfType(JwtEncodingException.class)
-				.isThrownBy(() -> this.jwsEncoder.encode(joseHeader, jwtClaimsSet))
+				.isThrownBy(() -> this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet)))
 				.withMessageContaining("Failed to select a JWK signing key -> key source error");
 	}
 
@@ -131,24 +113,40 @@ public class NimbusJwsEncoderTests {
 		this.jwkList.add(rsaJwk);
 		this.jwkList.add(rsaJwk);
 
-		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build();
 		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
 
 		assertThatExceptionOfType(JwtEncodingException.class)
-				.isThrownBy(() -> this.jwsEncoder.encode(joseHeader, jwtClaimsSet))
+				.isThrownBy(() -> this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet)))
 				.withMessageContaining("Found multiple JWK signing keys for algorithm 'RS256'");
 	}
 
 	@Test
 	public void encodeWhenJwkSelectEmptyThenThrowJwtEncodingException() {
-		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build();
 		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
 
 		assertThatExceptionOfType(JwtEncodingException.class)
-				.isThrownBy(() -> this.jwsEncoder.encode(joseHeader, jwtClaimsSet))
+				.isThrownBy(() -> this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet)))
 				.withMessageContaining("Failed to select a JWK signing key");
 	}
 
+	@Test
+	public void encodeWhenHeadersNotProvidedThenDefaulted() {
+		// @formatter:off
+		RSAKey rsaJwk = TestJwks.jwk(TestKeys.DEFAULT_PUBLIC_KEY, TestKeys.DEFAULT_PRIVATE_KEY)
+				.keyID("rsa-jwk-1")
+				.build();
+		this.jwkList.add(rsaJwk);
+		// @formatter:on
+
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
+
+		Jwt encodedJws = this.jwtEncoder.encode(JwtEncoderParameters.from(jwtClaimsSet));
+
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.ALG)).isEqualTo(SignatureAlgorithm.RS256);
+	}
+
 	@Test
 	public void encodeWhenJwkSelectWithProvidedKidThenSelected() {
 		// @formatter:off
@@ -162,10 +160,10 @@ public class NimbusJwsEncoderTests {
 		this.jwkList.add(rsaJwk2);
 		// @formatter:on
 
-		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).keyId(rsaJwk2.getKeyID()).build();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).keyId(rsaJwk2.getKeyID()).build();
 		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
 
-		Jwt encodedJws = this.jwsEncoder.encode(joseHeader, jwtClaimsSet);
+		Jwt encodedJws = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
 
 		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(rsaJwk2.getKeyID());
 	}
@@ -185,11 +183,11 @@ public class NimbusJwsEncoderTests {
 		this.jwkList.add(rsaJwk2);
 		// @formatter:on
 
-		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256)
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256)
 				.x509SHA256Thumbprint(rsaJwk1.getX509CertSHA256Thumbprint().toString()).build();
 		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
 
-		Jwt encodedJws = this.jwsEncoder.encode(joseHeader, jwtClaimsSet);
+		Jwt encodedJws = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
 
 		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.X5T_S256))
 				.isEqualTo(rsaJwk1.getX509CertSHA256Thumbprint().toString());
@@ -205,14 +203,15 @@ public class NimbusJwsEncoderTests {
 		// @formatter:on
 
 		this.jwkSource = mock(JWKSource.class);
-		this.jwsEncoder = new NimbusJwsEncoder(this.jwkSource);
+		this.jwtEncoder = new NimbusJwtEncoder(this.jwkSource);
 		given(this.jwkSource.get(any(), any())).willReturn(Collections.singletonList(rsaJwk));
 
-		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build();
 		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
 
 		assertThatExceptionOfType(JwtEncodingException.class)
-				.isThrownBy(() -> this.jwsEncoder.encode(joseHeader, jwtClaimsSet)).withMessageContaining(
+				.isThrownBy(() -> this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet)))
+				.withMessageContaining(
 						"Failed to create a JWS Signer -> The JWK use must be sig (signature) or unspecified");
 	}
 
@@ -226,12 +225,12 @@ public class NimbusJwsEncoderTests {
 		this.jwkList.add(rsaJwk);
 		// @formatter:on
 
-		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build();
 		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
 
-		Jwt encodedJws = this.jwsEncoder.encode(joseHeader, jwtClaimsSet);
+		Jwt encodedJws = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
 
-		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.ALG)).isEqualTo(joseHeader.getAlgorithm());
+		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.ALG)).isEqualTo(jwsHeader.getAlgorithm());
 		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.JKU)).isNull();
 		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.JWK)).isNull();
 		assertThat(encodedJws.getHeaders().get(JoseHeaderNames.KID)).isEqualTo(rsaJwk.getKeyID());
@@ -266,15 +265,15 @@ public class NimbusJwsEncoderTests {
 				return jwkSource.get(jwkSelector, context);
 			}
 		});
-		NimbusJwsEncoder jwsEncoder = new NimbusJwsEncoder(jwkSourceDelegate);
+		NimbusJwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSourceDelegate);
 
 		JwkListResultCaptor jwkListResultCaptor = new JwkListResultCaptor();
 		willAnswer(jwkListResultCaptor).given(jwkSourceDelegate).get(any(), any());
 
-		JoseHeader joseHeader = JoseHeader.withAlgorithm(SignatureAlgorithm.RS256).build();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256).build();
 		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet().build();
 
-		Jwt encodedJws = jwsEncoder.encode(joseHeader, jwtClaimsSet);
+		Jwt encodedJws = jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
 
 		JWK jwk1 = jwkListResultCaptor.getResult().get(0);
 		NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(((RSAKey) jwk1).toRSAPublicKey()).build();
@@ -282,7 +281,7 @@ public class NimbusJwsEncoderTests {
 
 		jwkSource.rotate(); // Simulate key rotation
 
-		encodedJws = jwsEncoder.encode(joseHeader, jwtClaimsSet);
+		encodedJws = jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
 
 		JWK jwk2 = jwkListResultCaptor.getResult().get(0);
 		jwtDecoder = NimbusJwtDecoder.withPublicKey(((RSAKey) jwk2).toRSAPublicKey()).build();

+ 7 - 22
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJoseHeaders.java → oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwsHeaders.java

@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.springframework.security.oauth2.client.endpoint;
+package org.springframework.security.oauth2.jwt;
 
 import java.util.Arrays;
 import java.util.HashMap;
@@ -22,36 +22,21 @@ 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 {
+public final class TestJwsHeaders {
 
-	private TestJoseHeaders() {
+	private TestJwsHeaders() {
 	}
 
-	static JoseHeader.Builder joseHeader() {
-		return joseHeader(SignatureAlgorithm.RS256);
+	public static JwsHeader.Builder jwsHeader() {
+		return jwsHeader(SignatureAlgorithm.RS256);
 	}
 
-	static JoseHeader.Builder joseHeader(SignatureAlgorithm signatureAlgorithm) {
+	public static JwsHeader.Builder jwsHeader(SignatureAlgorithm signatureAlgorithm) {
 		// @formatter:off
-		return JoseHeader.withAlgorithm(signatureAlgorithm)
+		return JwsHeader.with(signatureAlgorithm)
 				.jwkSetUrl("https://provider.com/oauth2/jwks")
 				.jwk(rsaJwk())
 				.keyId("keyId")

+ 3 - 18
oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJwtClaimsSets.java → oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwtClaimsSets.java

@@ -14,36 +14,21 @@
  * limitations under the License.
  */
 
-package org.springframework.security.oauth2.client.endpoint;
+package org.springframework.security.oauth2.jwt;
 
 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 {
+public final class TestJwtClaimsSets {
 
 	private TestJwtClaimsSets() {
 	}
 
-	static JwtClaimsSet.Builder jwtClaimsSet() {
+	public static JwtClaimsSet.Builder jwtClaimsSet() {
 		String issuer = "https://provider.com";
 		Instant issuedAt = Instant.now();
 		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);