Explorar o código

Add Jwt validator for the X509Certificate thumbprint claim

Closes gh-10538
Joe Grandja hai 1 ano
pai
achega
644cfa9f87

+ 3 - 0
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'

+ 9 - 1
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<Jwt> 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<Jwt> createDefaultWithValidators(List<OAuth2TokenValidator<Jwt>> validators) {
 		Assert.notEmpty(validators, "validators cannot be null or empty");
 		List<OAuth2TokenValidator<Jwt>> 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) {

+ 136 - 0
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 <a target="_blank" href=
+ * "https://datatracker.ietf.org/doc/html/rfc8705#section-3">3. Mutual-TLS Client
+ * Certificate-Bound Access Tokens</a>
+ * @see <a target="_blank" href=
+ * "https://datatracker.ietf.org/doc/html/rfc8705#section-3.1">3.1. JWT Certificate
+ * Thumbprint Confirmation Method</a>
+ */
+final class X509CertificateThumbprintValidator implements OAuth2TokenValidator<Jwt> {
+
+	static final Supplier<X509Certificate> DEFAULT_X509_CERTIFICATE_SUPPLIER = new DefaultX509CertificateSupplier();
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private final Supplier<X509Certificate> x509CertificateSupplier;
+
+	X509CertificateThumbprintValidator(Supplier<X509Certificate> x509CertificateSupplier) {
+		Assert.notNull(x509CertificateSupplier, "x509CertificateSupplier cannot be null");
+		this.x509CertificateSupplier = x509CertificateSupplier;
+	}
+
+	@Override
+	public OAuth2TokenValidatorResult validate(Jwt jwt) {
+		Map<String, Object> 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<X509Certificate> {
+
+		@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;
+		}
+
+	}
+
+}

+ 75 - 0
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() {
+	}
+
+}

+ 165 - 0
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));
+	}
+
+}

+ 3 - 1
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

+ 135 - 0
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();
+	}
+
+}