diff --git a/etc/checkstyle/checkstyle-suppressions.xml b/etc/checkstyle/checkstyle-suppressions.xml index 2dcea44d2f..e42d8124ea 100644 --- a/etc/checkstyle/checkstyle-suppressions.xml +++ b/etc/checkstyle/checkstyle-suppressions.xml @@ -52,4 +52,5 @@ + diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverter.java index 68bba82d5c..03c8611af3 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverter.java +++ b/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 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 parameters = new LinkedMultiValueMap<>(); parameters.set(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE, CLIENT_ASSERTION_TYPE_VALUE); @@ -186,16 +191,16 @@ public final class NimbusJwtClientAuthenticationParametersConverter 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.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.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"); - } - -} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverterTests.java index 42fb1dc9f8..d916424582 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwtClientAuthenticationParametersConverterTests.java +++ b/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; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeader.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JoseHeader.java similarity index 72% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeader.java rename to oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JoseHeader.java index b148e670a3..f40fb09a55 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeader.java +++ b/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 JWT JOSE * Header @@ -60,11 +45,12 @@ import org.springframework.util.Assert; * @see JWE JOSE * Header */ -final class JoseHeader { +class JoseHeader { private final Map headers; - private JoseHeader(Map headers) { + protected JoseHeader(Map 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 getAlgorithm() { + public 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 getJwk() { + public Map 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 getX509CertificateChain() { + public List 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 getCritical() { + public Set getCritical() { return getHeader(JoseHeaderNames.CRIT); } @@ -178,7 +164,7 @@ final class JoseHeader { * Returns the headers. * @return the headers */ - Map getHeaders() { + public Map getHeaders() { return this.headers; } @@ -189,53 +175,38 @@ final class JoseHeader { * @return the header value */ @SuppressWarnings("unchecked") - T getHeader(String name) { + public 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> { - /** - * A builder for {@link JoseHeader}. - */ - static final class Builder { + private final Map headers = new HashMap<>(); - final Map headers = new HashMap<>(); + protected AbstractBuilder() { + } - private Builder(JwaAlgorithm jwaAlgorithm) { - algorithm(jwaAlgorithm); + protected Map 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 jwk) { + public B jwk(Map 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 x509CertificateChain) { + public B x509CertificateChain(List 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 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()); + ((Set) 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> headersConsumer) { + public B headers(Consumer> 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); diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeaderNames.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JoseHeaderNames.java similarity index 74% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeaderNames.java rename to oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JoseHeaderNames.java index 41abd7eeba..a53318584f 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JoseHeaderNames.java +++ b/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 JWT JOSE * Header * @see JWS JOSE @@ -47,53 +31,53 @@ package org.springframework.security.oauth2.client.endpoint; * @see JWE JOSE * Header */ -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() { } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwsHeader.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwsHeader.java new file mode 100644 index 0000000000..9b8ee4721a --- /dev/null +++ b/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 JWS JOSE + * Header + */ +public final class JwsHeader extends JoseHeader { + + private JwsHeader(Map 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 { + + 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()); + } + + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSet.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimsSet.java similarity index 77% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSet.java rename to oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtClaimsSet.java index d383c04b66..eb70bf60f6 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSet.java +++ b/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 JWT Claims * Set */ -final class JwtClaimsSet implements JwtClaimAccessor { +public final class JwtClaimsSet implements JwtClaimAccessor { private final Map 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 claims = new HashMap<>(); + private final Map 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 audience) { + public Builder audience(List 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> claimsConsumer) { + public Builder claims(Consumer> 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). diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoder.java new file mode 100644 index 0000000000..4799fbe50c --- /dev/null +++ b/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. + * + * + * 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 JSON Web Token + * (JWT) + * @see JSON Web Signature + * (JWS) + * @see JSON Web Encryption + * (JWE) + * @see JWS + * Compact Serialization + * @see JWE + * Compact Serialization + */ +@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; + +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoderParameters.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoderParameters.java new file mode 100644 index 0000000000..03bfc6ac96 --- /dev/null +++ b/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; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtEncodingException.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java similarity index 55% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtEncodingException.java rename to oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java index 53c82b13bd..9b48f5c4a2 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtEncodingException.java +++ b/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); } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.java similarity index 84% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoder.java rename to oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.java index c6d681a623..2de3e64a81 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoder.java +++ b/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. * * * NOTE: 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 JSON Web Token @@ -89,10 +74,12 @@ import org.springframework.util.StringUtils; * @see Nimbus * JOSE + JWT SDK */ -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 jwsSigners = new ConcurrentHashMap<>(); @@ -100,17 +87,23 @@ final class NimbusJwsEncoder { private final JWKSource 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 jwkSource) { + public NimbusJwtEncoder(JWKSource 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 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) { diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwsHeaderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwsHeaderTests.java new file mode 100644 index 0000000000..a1262a850f --- /dev/null +++ b/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.getHeader("critical-header1-name")).isEqualTo("critical-header1-value"); + assertThat(jwsHeader.getHeader("critical-header2-name")).isEqualTo("critical-header2-value"); + assertThat(jwsHeader.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"); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSetTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtClaimsSetTests.java similarity index 81% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSetTests.java rename to oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtClaimsSetTests.java index 9a23a2fb52..3e0c802842 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSetTests.java +++ b/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 diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJweEncoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJweEncoderTests.java new file mode 100644 index 0000000000..cfb1d7cd11 --- /dev/null +++ b/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 jwkList; + + private JWKSource 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.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.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.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.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 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 { + + 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> 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 JWE_HEADER_CONVERTER = new JweHeaderConverter(); + + private static final Converter JWT_CLAIMS_SET_CONVERTER = new JwtClaimsSetConverter(); + + private final JWKSource jwkSource; + + private final JwtEncoder jwsEncoder; + + private NimbusJweEncoder(JWKSource 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 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 { + + @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 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 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 critical = headers.getCritical(); + if (!CollectionUtils.isEmpty(critical)) { + builder.criticalParams(critical); + } + + Map 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 { + + @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 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 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(); + } + + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoderTests.java similarity index 78% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoderTests.java rename to oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoderTests.java index b97d638b07..60c99b13ce 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoderTests.java +++ b/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 jwkList; private JWKSource 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(); diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJoseHeaders.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwsHeaders.java similarity index 61% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJoseHeaders.java rename to oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwsHeaders.java index ffda877694..6cbd78edcd 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJoseHeaders.java +++ b/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") diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJwtClaimsSets.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwtClaimsSets.java similarity index 63% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJwtClaimsSets.java rename to oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwtClaimsSets.java index 1b31197945..4cb79f6192 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJwtClaimsSets.java +++ b/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);
+ * 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 JSON Web Token + * (JWT) + * @see JSON Web Signature + * (JWS) + * @see JSON Web Encryption + * (JWE) + * @see JWS + * Compact Serialization + * @see JWE + * Compact Serialization + */ +@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; + +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoderParameters.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncoderParameters.java new file mode 100644 index 0000000000..03bfc6ac96 --- /dev/null +++ b/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; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtEncodingException.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java similarity index 55% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtEncodingException.java rename to oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtEncodingException.java index 53c82b13bd..9b48f5c4a2 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/JwtEncodingException.java +++ b/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); } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.java similarity index 84% rename from oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoder.java rename to oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoder.java index c6d681a623..2de3e64a81 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoder.java +++ b/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. * *
* NOTE: 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 JSON Web Token @@ -89,10 +74,12 @@ import org.springframework.util.StringUtils; * @see Nimbus * JOSE + JWT SDK */ -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 jwsSigners = new ConcurrentHashMap<>(); @@ -100,17 +87,23 @@ final class NimbusJwsEncoder { private final JWKSource 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 jwkSource) { + public NimbusJwtEncoder(JWKSource 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 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) { diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwsHeaderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwsHeaderTests.java new file mode 100644 index 0000000000..a1262a850f --- /dev/null +++ b/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.getHeader("critical-header1-name")).isEqualTo("critical-header1-value"); + assertThat(jwsHeader.getHeader("critical-header2-name")).isEqualTo("critical-header2-value"); + assertThat(jwsHeader.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"); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSetTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtClaimsSetTests.java similarity index 81% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSetTests.java rename to oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtClaimsSetTests.java index 9a23a2fb52..3e0c802842 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/JwtClaimsSetTests.java +++ b/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 diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJweEncoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJweEncoderTests.java new file mode 100644 index 0000000000..cfb1d7cd11 --- /dev/null +++ b/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 jwkList; + + private JWKSource 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.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.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.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.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 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 { + + 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> 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 JWE_HEADER_CONVERTER = new JweHeaderConverter(); + + private static final Converter JWT_CLAIMS_SET_CONVERTER = new JwtClaimsSetConverter(); + + private final JWKSource jwkSource; + + private final JwtEncoder jwsEncoder; + + private NimbusJweEncoder(JWKSource 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 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 { + + @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 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 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 critical = headers.getCritical(); + if (!CollectionUtils.isEmpty(critical)) { + builder.criticalParams(critical); + } + + Map 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 { + + @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 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 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(); + } + + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoderTests.java similarity index 78% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoderTests.java rename to oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtEncoderTests.java index b97d638b07..60c99b13ce 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/NimbusJwsEncoderTests.java +++ b/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 jwkList; private JWKSource 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(); diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJoseHeaders.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwsHeaders.java similarity index 61% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJoseHeaders.java rename to oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwsHeaders.java index ffda877694..6cbd78edcd 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJoseHeaders.java +++ b/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") diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJwtClaimsSets.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwtClaimsSets.java similarity index 63% rename from oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJwtClaimsSets.java rename to oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/TestJwtClaimsSets.java index 1b31197945..4cb79f6192 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/TestJwtClaimsSets.java +++ b/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);