Add Jwt validator for the X509Certificate thumbprint claim

Closes gh-10538
This commit is contained in:
Joe Grandja 2024-04-10 15:57:02 -04:00
parent 2d24e09665
commit 644cfa9f87
7 changed files with 526 additions and 2 deletions

View File

@ -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'

View File

@ -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) {

View File

@ -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;
}
}
}

View File

@ -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() {
}
}

View File

@ -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));
}
}

View File

@ -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

View File

@ -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();
}
}