From 644cfa9f875409d2b2bf01cd791d1a906e44c500 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Wed, 10 Apr 2024 15:57:02 -0400 Subject: [PATCH] Add Jwt validator for the X509Certificate thumbprint claim Closes gh-10538 --- .../spring-security-oauth2-jose.gradle | 3 + .../security/oauth2/jwt/JwtValidators.java | 10 +- .../X509CertificateThumbprintValidator.java | 136 +++++++++++++++ .../oauth2/jose/TestX509Certificates.java | 75 ++++++++ .../oauth2/jose/X509CertificateUtils.java | 165 ++++++++++++++++++ .../oauth2/jwt/JwtValidatorsTests.java | 4 +- ...09CertificateThumbprintValidatorTests.java | 135 ++++++++++++++ 7 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/X509CertificateThumbprintValidator.java create mode 100644 oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/TestX509Certificates.java create mode 100644 oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/X509CertificateUtils.java create mode 100644 oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/X509CertificateThumbprintValidatorTests.java diff --git a/oauth2/oauth2-jose/spring-security-oauth2-jose.gradle b/oauth2/oauth2-jose/spring-security-oauth2-jose.gradle index 8290b8579e..c5b4b113df 100644 --- a/oauth2/oauth2-jose/spring-security-oauth2-jose.gradle +++ b/oauth2/oauth2-jose/spring-security-oauth2-jose.gradle @@ -10,6 +10,9 @@ dependencies { optional 'io.projectreactor:reactor-core' optional 'org.springframework:spring-webflux' + testImplementation "org.bouncycastle:bcpkix-jdk15on" + testImplementation "org.bouncycastle:bcprov-jdk15on" + testImplementation "jakarta.servlet:jakarta.servlet-api" testImplementation 'com.squareup.okhttp3:mockwebserver' testImplementation 'io.projectreactor.netty:reactor-netty' testImplementation 'com.fasterxml.jackson.core:jackson-databind' diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java index 7618d2911d..8c2fa20909 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java @@ -68,7 +68,9 @@ public final class JwtValidators { * supplied */ public static OAuth2TokenValidator createDefault() { - return new DelegatingOAuth2TokenValidator<>(Arrays.asList(new JwtTimestampValidator())); + return new DelegatingOAuth2TokenValidator<>( + Arrays.asList(new JwtTimestampValidator(), new X509CertificateThumbprintValidator( + X509CertificateThumbprintValidator.DEFAULT_X509_CERTIFICATE_SUPPLIER))); } /** @@ -84,6 +86,12 @@ public final class JwtValidators { public static OAuth2TokenValidator createDefaultWithValidators(List> validators) { Assert.notEmpty(validators, "validators cannot be null or empty"); List> tokenValidators = new ArrayList<>(validators); + X509CertificateThumbprintValidator x509CertificateThumbprintValidator = CollectionUtils + .findValueOfType(tokenValidators, X509CertificateThumbprintValidator.class); + if (x509CertificateThumbprintValidator == null) { + tokenValidators.add(0, new X509CertificateThumbprintValidator( + X509CertificateThumbprintValidator.DEFAULT_X509_CERTIFICATE_SUPPLIER)); + } JwtTimestampValidator jwtTimestampValidator = CollectionUtils.findValueOfType(tokenValidators, JwtTimestampValidator.class); if (jwtTimestampValidator == null) { diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/X509CertificateThumbprintValidator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/X509CertificateThumbprintValidator.java new file mode 100644 index 0000000000..1344d952a4 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/X509CertificateThumbprintValidator.java @@ -0,0 +1,136 @@ +/* + * Copyright 2002-2024 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.security.MessageDigest; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.Map; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; + +/** + * An {@link OAuth2TokenValidator} responsible for validating the {@code x5t#S256} claim + * (if available) in the {@link Jwt} against the SHA-256 Thumbprint of the supplied + * {@code X509Certificate}. + * + * @author Joe Grandja + * @since 6.3 + * @see OAuth2TokenValidator + * @see Jwt + * @see 3. Mutual-TLS Client + * Certificate-Bound Access Tokens + * @see 3.1. JWT Certificate + * Thumbprint Confirmation Method + */ +final class X509CertificateThumbprintValidator implements OAuth2TokenValidator { + + static final Supplier DEFAULT_X509_CERTIFICATE_SUPPLIER = new DefaultX509CertificateSupplier(); + + private final Log logger = LogFactory.getLog(getClass()); + + private final Supplier x509CertificateSupplier; + + X509CertificateThumbprintValidator(Supplier x509CertificateSupplier) { + Assert.notNull(x509CertificateSupplier, "x509CertificateSupplier cannot be null"); + this.x509CertificateSupplier = x509CertificateSupplier; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + Map confirmationMethodClaim = jwt.getClaim("cnf"); + String x509CertificateThumbprintClaim = null; + if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("x5t#S256")) { + x509CertificateThumbprintClaim = (String) confirmationMethodClaim.get("x5t#S256"); + } + if (x509CertificateThumbprintClaim == null) { + return OAuth2TokenValidatorResult.success(); + } + + X509Certificate x509Certificate = this.x509CertificateSupplier.get(); + if (x509Certificate == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, + "Unable to obtain X509Certificate from current request.", null); + if (this.logger.isDebugEnabled()) { + this.logger.debug(error.toString()); + } + return OAuth2TokenValidatorResult.failure(error); + } + + String x509CertificateThumbprint; + try { + x509CertificateThumbprint = computeSHA256Thumbprint(x509Certificate); + } + catch (Exception ex) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, + "Failed to compute SHA-256 Thumbprint for X509Certificate.", null); + if (this.logger.isDebugEnabled()) { + this.logger.debug(error.toString()); + } + return OAuth2TokenValidatorResult.failure(error); + } + + if (!x509CertificateThumbprint.equals(x509CertificateThumbprintClaim)) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, + "Invalid SHA-256 Thumbprint for X509Certificate.", null); + if (this.logger.isDebugEnabled()) { + this.logger.debug(error.toString()); + } + return OAuth2TokenValidatorResult.failure(error); + } + + return OAuth2TokenValidatorResult.success(); + } + + static String computeSHA256Thumbprint(X509Certificate x509Certificate) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(x509Certificate.getEncoded()); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } + + private static final class DefaultX509CertificateSupplier implements Supplier { + + @Override + public X509Certificate get() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes == null) { + return null; + } + + X509Certificate[] clientCertificateChain = (X509Certificate[]) requestAttributes + .getAttribute("jakarta.servlet.request.X509Certificate", RequestAttributes.SCOPE_REQUEST); + + return (clientCertificateChain != null && clientCertificateChain.length > 0) ? clientCertificateChain[0] + : null; + } + + } + +} diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/TestX509Certificates.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/TestX509Certificates.java new file mode 100644 index 0000000000..9e5a0740f0 --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/TestX509Certificates.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jose; + +import java.security.KeyPair; +import java.security.cert.X509Certificate; + +/** + * @author Joe Grandja + * @since 6.3 + */ +public final class TestX509Certificates { + + public static final X509Certificate[] DEFAULT_PKI_CERTIFICATE; + static { + try { + // Generate the Root certificate (Trust Anchor or most-trusted CA) + KeyPair rootKeyPair = X509CertificateUtils.generateRSAKeyPair(); + String distinguishedName = "CN=spring-samples-trusted-ca, OU=Spring Samples, O=Spring, C=US"; + X509Certificate rootCertificate = X509CertificateUtils.createTrustAnchorCertificate(rootKeyPair, + distinguishedName); + + // Generate the CA (intermediary) certificate + KeyPair caKeyPair = X509CertificateUtils.generateRSAKeyPair(); + distinguishedName = "CN=spring-samples-ca, OU=Spring Samples, O=Spring, C=US"; + X509Certificate caCertificate = X509CertificateUtils.createCACertificate(rootCertificate, + rootKeyPair.getPrivate(), caKeyPair.getPublic(), distinguishedName); + + // Generate certificate for subject1 + KeyPair subject1KeyPair = X509CertificateUtils.generateRSAKeyPair(); + distinguishedName = "CN=subject1, OU=Spring Samples, O=Spring, C=US"; + X509Certificate subject1Certificate = X509CertificateUtils.createEndEntityCertificate(caCertificate, + caKeyPair.getPrivate(), subject1KeyPair.getPublic(), distinguishedName); + + DEFAULT_PKI_CERTIFICATE = new X509Certificate[] { subject1Certificate, caCertificate, rootCertificate }; + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + public static final X509Certificate[] DEFAULT_SELF_SIGNED_CERTIFICATE; + static { + try { + // Generate self-signed certificate for subject1 + KeyPair keyPair = X509CertificateUtils.generateRSAKeyPair(); + String distinguishedName = "CN=subject1, OU=Spring Samples, O=Spring, C=US"; + X509Certificate subject1SelfSignedCertificate = X509CertificateUtils.createTrustAnchorCertificate(keyPair, + distinguishedName); + + DEFAULT_SELF_SIGNED_CERTIFICATE = new X509Certificate[] { subject1SelfSignedCertificate }; + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private TestX509Certificates() { + } + +} diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/X509CertificateUtils.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/X509CertificateUtils.java new file mode 100644 index 0000000000..873251628d --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jose/X509CertificateUtils.java @@ -0,0 +1,165 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.jose; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.security.spec.RSAKeyGenParameterSpec; +import java.util.Calendar; +import java.util.Date; + +import javax.security.auth.x500.X500Principal; + +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * @author Joe Grandja + * @since 6.3 + */ +final class X509CertificateUtils { + + private static final String BC_PROVIDER = "BC"; + + private static final String SHA256_RSA_SIGNATURE_ALGORITHM = "SHA256withRSA"; + + private static final Date DEFAULT_START_DATE; + + private static final Date DEFAULT_END_DATE; + + static { + Security.addProvider(new BouncyCastleProvider()); + + // Setup default certificate start date to yesterday and end date for 1 year + // validity + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.DATE, -1); + DEFAULT_START_DATE = calendar.getTime(); + calendar.add(Calendar.YEAR, 1); + DEFAULT_END_DATE = calendar.getTime(); + } + + private X509CertificateUtils() { + } + + static KeyPair generateRSAKeyPair() { + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", BC_PROVIDER); + keyPairGenerator.initialize(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4)); + keyPair = keyPairGenerator.generateKeyPair(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + return keyPair; + } + + static X509Certificate createTrustAnchorCertificate(KeyPair keyPair, String distinguishedName) throws Exception { + X500Principal subject = new X500Principal(distinguishedName); + BigInteger serialNum = new BigInteger(Long.toString(new SecureRandom().nextLong())); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(subject, serialNum, DEFAULT_START_DATE, + DEFAULT_END_DATE, subject, keyPair.getPublic()); + + // Add Extensions + JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + certBuilder + // A BasicConstraints to mark root certificate as CA certificate + .addExtension(Extension.basicConstraints, true, new BasicConstraints(true)) + .addExtension(Extension.subjectKeyIdentifier, false, + extensionUtils.createSubjectKeyIdentifier(keyPair.getPublic())); + + ContentSigner signer = new JcaContentSignerBuilder(SHA256_RSA_SIGNATURE_ALGORITHM).setProvider(BC_PROVIDER) + .build(keyPair.getPrivate()); + + JcaX509CertificateConverter converter = new JcaX509CertificateConverter().setProvider(BC_PROVIDER); + + return converter.getCertificate(certBuilder.build(signer)); + } + + static X509Certificate createCACertificate(X509Certificate signerCert, PrivateKey signerKey, PublicKey certKey, + String distinguishedName) throws Exception { + + X500Principal subject = new X500Principal(distinguishedName); + BigInteger serialNum = new BigInteger(Long.toString(new SecureRandom().nextLong())); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(signerCert.getSubjectX500Principal(), + serialNum, DEFAULT_START_DATE, DEFAULT_END_DATE, subject, certKey); + + // Add Extensions + JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + certBuilder + // A BasicConstraints to mark as CA certificate and how many CA certificates + // can follow it in the chain + // (with 0 meaning the chain ends with the next certificate in the chain). + .addExtension(Extension.basicConstraints, true, new BasicConstraints(0)) + // KeyUsage specifies what the public key in the certificate can be used for. + // In this case, it can be used for signing other certificates and/or + // signing Certificate Revocation Lists (CRLs). + .addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)) + .addExtension(Extension.authorityKeyIdentifier, false, + extensionUtils.createAuthorityKeyIdentifier(signerCert)) + .addExtension(Extension.subjectKeyIdentifier, false, extensionUtils.createSubjectKeyIdentifier(certKey)); + + ContentSigner signer = new JcaContentSignerBuilder(SHA256_RSA_SIGNATURE_ALGORITHM).setProvider(BC_PROVIDER) + .build(signerKey); + + JcaX509CertificateConverter converter = new JcaX509CertificateConverter().setProvider(BC_PROVIDER); + + return converter.getCertificate(certBuilder.build(signer)); + } + + static X509Certificate createEndEntityCertificate(X509Certificate signerCert, PrivateKey signerKey, + PublicKey certKey, String distinguishedName) throws Exception { + + X500Principal subject = new X500Principal(distinguishedName); + BigInteger serialNum = new BigInteger(Long.toString(new SecureRandom().nextLong())); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(signerCert.getSubjectX500Principal(), + serialNum, DEFAULT_START_DATE, DEFAULT_END_DATE, subject, certKey); + + JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); + certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)) + .addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature)) + .addExtension(Extension.authorityKeyIdentifier, false, + extensionUtils.createAuthorityKeyIdentifier(signerCert)) + .addExtension(Extension.subjectKeyIdentifier, false, extensionUtils.createSubjectKeyIdentifier(certKey)); + + ContentSigner signer = new JcaContentSignerBuilder(SHA256_RSA_SIGNATURE_ALGORITHM).setProvider(BC_PROVIDER) + .build(signerKey); + + JcaX509CertificateConverter converter = new JcaX509CertificateConverter().setProvider(BC_PROVIDER); + + return converter.getCertificate(certBuilder.build(signer)); + } + +} diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtValidatorsTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtValidatorsTests.java index 9678f528b0..5c4cf6897c 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtValidatorsTests.java +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtValidatorsTests.java @@ -46,6 +46,7 @@ public class JwtValidatorsTests { assertThat(containsByType(validator, JwtIssuerValidator.class)).isTrue(); assertThat(containsByType(validator, JwtTimestampValidator.class)).isTrue(); + assertThat(containsByType(validator, X509CertificateThumbprintValidator.class)).isTrue(); } @Test @@ -58,7 +59,8 @@ public class JwtValidatorsTests { .getField(delegatingOAuth2TokenValidator, "tokenValidators"); assertThat(containsByType(validator, JwtTimestampValidator.class)).isTrue(); - assertThat(Objects.requireNonNull(tokenValidators).size()).isEqualTo(1); + assertThat(containsByType(validator, X509CertificateThumbprintValidator.class)).isTrue(); + assertThat(Objects.requireNonNull(tokenValidators).size()).isEqualTo(2); } @Test diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/X509CertificateThumbprintValidatorTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/X509CertificateThumbprintValidatorTests.java new file mode 100644 index 0000000000..cd6880191d --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/X509CertificateThumbprintValidatorTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-2024 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.Collections; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.jose.TestX509Certificates; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * @author Joe Grandja + * @since 6.3 + */ +class X509CertificateThumbprintValidatorTests { + + private final X509CertificateThumbprintValidator validator = new X509CertificateThumbprintValidator( + X509CertificateThumbprintValidator.DEFAULT_X509_CERTIFICATE_SUPPLIER); + + @AfterEach + void cleanup() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void constructorWhenX509CertificateSupplierNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new X509CertificateThumbprintValidator(null)).withMessage("x509CertificateSupplier cannot be null"); + // @formatter:on + } + + @Test + void validateWhenCnfClaimNotAvailableThenSuccess() { + Jwt jwt = TestJwts.jwt().build(); + assertThat(this.validator.validate(jwt).hasErrors()).isFalse(); + } + + @Test + void validateWhenX5tClaimNotAvailableThenSuccess() { + // @formatter:off + Jwt jwt = TestJwts.jwt() + .claim("cnf", Collections.emptyMap()) + .build(); + // @formatter:on + assertThat(this.validator.validate(jwt).hasErrors()).isFalse(); + } + + @Test + void validateWhenX509CertificateMissingThenHasErrors() throws Exception { + String sha256Thumbprint = X509CertificateThumbprintValidator + .computeSHA256Thumbprint(TestX509Certificates.DEFAULT_PKI_CERTIFICATE[0]); + // @formatter:off + Jwt jwt = TestJwts.jwt() + .claim("cnf", Collections.singletonMap("x5t#S256", sha256Thumbprint)) + .build(); + // @formatter:on + + // @formatter:off + assertThat(this.validator.validate(jwt).getErrors()) + .hasSize(1) + .first() + .satisfies((error) -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).isEqualTo("Unable to obtain X509Certificate from current request."); + }); + // @formatter:on + } + + @Test + void validateWhenX509CertificateThumbprintInvalidThenHasErrors() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute("jakarta.servlet.request.X509Certificate", + TestX509Certificates.DEFAULT_SELF_SIGNED_CERTIFICATE); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, null)); + + String sha256Thumbprint = X509CertificateThumbprintValidator + .computeSHA256Thumbprint(TestX509Certificates.DEFAULT_PKI_CERTIFICATE[0]); + // @formatter:off + Jwt jwt = TestJwts.jwt() + .claim("cnf", Collections.singletonMap("x5t#S256", sha256Thumbprint)) + .build(); + // @formatter:on + + // @formatter:off + assertThat(this.validator.validate(jwt).getErrors()) + .hasSize(1) + .first() + .satisfies((error) -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN); + assertThat(error.getDescription()).isEqualTo("Invalid SHA-256 Thumbprint for X509Certificate."); + }); + // @formatter:on + } + + @Test + void validateWhenX509CertificateThumbprintValidThenSuccess() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute("jakarta.servlet.request.X509Certificate", TestX509Certificates.DEFAULT_PKI_CERTIFICATE); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, null)); + + String sha256Thumbprint = X509CertificateThumbprintValidator + .computeSHA256Thumbprint(TestX509Certificates.DEFAULT_PKI_CERTIFICATE[0]); + // @formatter:off + Jwt jwt = TestJwts.jwt() + .claim("cnf", Collections.singletonMap("x5t#S256", sha256Thumbprint)) + .build(); + // @formatter:on + + assertThat(this.validator.validate(jwt).hasErrors()).isFalse(); + } + +}