Browse Source

Add self-signed certificate Mutual-TLS client authentication method

Issue gh-101

Closes gh-1559
Joe Grandja 1 year ago
parent
commit
79fe240d1e
10 changed files with 478 additions and 20 deletions
  1. 20 4
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProvider.java
  2. 180 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509SelfSignedCertificateVerifier.java
  3. 1 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java
  4. 1 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java
  5. 10 3
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverter.java
  6. 233 4
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProviderTests.java
  7. 1 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java
  8. 3 3
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java
  9. 3 3
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java
  10. 26 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverterTests.java

+ 20 - 4
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProvider.java

@@ -38,7 +38,8 @@ import org.springframework.util.StringUtils;
 
 /**
  * An {@link AuthenticationProvider} implementation used for OAuth 2.0 Client Authentication,
- * which authenticates the client {@code X509Certificate} received when the {@code tls_client_auth} authentication method is used.
+ * which authenticates the client {@code X509Certificate} received
+ * when the {@code tls_client_auth} or {@code self_signed_tls_client_auth} authentication method is used.
  *
  * @author Joe Grandja
  * @since 1.3
@@ -51,10 +52,14 @@ public final class X509ClientCertificateAuthenticationProvider implements Authen
 	private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1";
 	private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
 			new ClientAuthenticationMethod("tls_client_auth");
+	private static final ClientAuthenticationMethod SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
+			new ClientAuthenticationMethod("self_signed_tls_client_auth");
 	private final Log logger = LogFactory.getLog(getClass());
 	private final RegisteredClientRepository registeredClientRepository;
 	private final CodeVerifierAuthenticator codeVerifierAuthenticator;
-	private Consumer<OAuth2ClientAuthenticationContext> certificateVerifier = this::verifyX509CertificateSubjectDN;
+	private final Consumer<OAuth2ClientAuthenticationContext> selfSignedCertificateVerifier =
+			new X509SelfSignedCertificateVerifier();
+	private Consumer<OAuth2ClientAuthenticationContext> certificateVerifier = this::verifyX509Certificate;
 
 	/**
 	 * Constructs a {@code X509ClientCertificateAuthenticationProvider} using the provided parameters.
@@ -75,7 +80,8 @@ public final class X509ClientCertificateAuthenticationProvider implements Authen
 		OAuth2ClientAuthenticationToken clientAuthentication =
 				(OAuth2ClientAuthenticationToken) authentication;
 
-		if (!TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) {
+		if (!TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod()) &&
+				!SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) {
 			return null;
 		}
 
@@ -127,7 +133,8 @@ public final class X509ClientCertificateAuthenticationProvider implements Authen
 	/**
 	 * Sets the {@code Consumer} providing access to the {@link OAuth2ClientAuthenticationContext}
 	 * and is responsible for verifying the client {@code X509Certificate} associated in the {@link OAuth2ClientAuthenticationToken}.
-	 * The default implementation verifies the {@link ClientSettings#getX509CertificateSubjectDN() expected subject distinguished name}.
+	 * The default implementation for the {@code tls_client_auth} authentication method
+	 * verifies the {@link ClientSettings#getX509CertificateSubjectDN() expected subject distinguished name}.
 	 *
 	 * <p>
 	 * <b>NOTE:</b> If verification fails, an {@link OAuth2AuthenticationException} MUST be thrown.
@@ -139,6 +146,15 @@ public final class X509ClientCertificateAuthenticationProvider implements Authen
 		this.certificateVerifier = certificateVerifier;
 	}
 
+	private void verifyX509Certificate(OAuth2ClientAuthenticationContext clientAuthenticationContext) {
+		OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication();
+		if (SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) {
+			this.selfSignedCertificateVerifier.accept(clientAuthenticationContext);
+		} else {
+			verifyX509CertificateSubjectDN(clientAuthenticationContext);
+		}
+	}
+
 	private void verifyX509CertificateSubjectDN(OAuth2ClientAuthenticationContext clientAuthenticationContext) {
 		OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication();
 		RegisteredClient registeredClient = clientAuthenticationContext.getRegisteredClient();

+ 180 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509SelfSignedCertificateVerifier.java

@@ -0,0 +1,180 @@
+/*
+ * Copyright 2020-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.server.authorization.authentication;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.PublicKey;
+import java.security.cert.X509Certificate;
+import java.text.ParseException;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import javax.security.auth.x500.X500Principal;
+
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKMatcher;
+import com.nimbusds.jose.jwk.JWKSet;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.RequestEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.RestOperations;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * The default {@code X509Certificate} verifier for the {@code self_signed_tls_client_auth} authentication method.
+ *
+ * @author Joe Grandja
+ * @since 1.3
+ * @see X509ClientCertificateAuthenticationProvider#setCertificateVerifier(Consumer)
+ */
+final class X509SelfSignedCertificateVerifier implements Consumer<OAuth2ClientAuthenticationContext> {
+	private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1";
+	private static final JWKMatcher HAS_X509_CERT_CHAIN_MATCHER = new JWKMatcher.Builder().hasX509CertChain(true).build();
+	private final Function<RegisteredClient, JWKSet> jwkSetSupplier = new JwkSetSupplier();
+
+	@Override
+	public void accept(OAuth2ClientAuthenticationContext clientAuthenticationContext) {
+		OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication();
+		RegisteredClient registeredClient = clientAuthenticationContext.getRegisteredClient();
+		X509Certificate[] clientCertificateChain = (X509Certificate[]) clientAuthentication.getCredentials();
+		X509Certificate clientCertificate = clientCertificateChain[0];
+
+		X500Principal issuer = clientCertificate.getIssuerX500Principal();
+		X500Principal subject = clientCertificate.getSubjectX500Principal();
+		if (issuer == null || !issuer.equals(subject)) {
+			throwInvalidClient("x509_certificate_issuer");
+		}
+
+		JWKSet jwkSet = this.jwkSetSupplier.apply(registeredClient);
+
+		boolean publicKeyMatches = false;
+		for (JWK jwk : jwkSet.filter(HAS_X509_CERT_CHAIN_MATCHER).getKeys()) {
+			X509Certificate x509Certificate = jwk.getParsedX509CertChain().get(0);
+			PublicKey publicKey = x509Certificate.getPublicKey();
+			if (Arrays.equals(clientCertificate.getPublicKey().getEncoded(), publicKey.getEncoded())) {
+				publicKeyMatches = true;
+				break;
+			}
+		}
+
+		if (!publicKeyMatches) {
+			throwInvalidClient("x509_certificate");
+		}
+	}
+
+	private static void throwInvalidClient(String parameterName) {
+		throwInvalidClient(parameterName, null);
+	}
+
+	private static void throwInvalidClient(String parameterName, Throwable cause) {
+		OAuth2Error error = new OAuth2Error(
+				OAuth2ErrorCodes.INVALID_CLIENT,
+				"Client authentication failed: " + parameterName,
+				ERROR_URI
+		);
+		throw new OAuth2AuthenticationException(error, error.toString(), cause);
+	}
+
+	private static class JwkSetSupplier implements Function<RegisteredClient, JWKSet> {
+		private static final MediaType APPLICATION_JWK_SET_JSON = new MediaType("application", "jwk-set+json");
+		private final RestOperations restOperations;
+		private final Map<String, Supplier<JWKSet>> jwkSets = new ConcurrentHashMap<>();
+
+		private JwkSetSupplier() {
+			SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
+			requestFactory.setConnectTimeout(15_000);
+			requestFactory.setReadTimeout(15_000);
+			this.restOperations = new RestTemplate(requestFactory);
+		}
+
+		@Override
+		public JWKSet apply(RegisteredClient registeredClient) {
+			Supplier<JWKSet> jwkSetSupplier = this.jwkSets.computeIfAbsent(
+					registeredClient.getId(), (key) -> {
+						if (!StringUtils.hasText(registeredClient.getClientSettings().getJwkSetUrl())) {
+							throwInvalidClient("client_jwk_set_url");
+						}
+						return new JwkSetHolder(registeredClient.getClientSettings().getJwkSetUrl());
+					});
+			return jwkSetSupplier.get();
+		}
+
+		private JWKSet retrieve(String jwkSetUrl) {
+			URI jwkSetUri = null;
+			try {
+				jwkSetUri = new URI(jwkSetUrl);
+			} catch (URISyntaxException ex) {
+				throwInvalidClient("jwk_set_uri", ex);
+			}
+
+			HttpHeaders headers = new HttpHeaders();
+			headers.setAccept(Arrays.asList(MediaType.APPLICATION_JSON, APPLICATION_JWK_SET_JSON));
+			RequestEntity<Void> request = new RequestEntity<>(headers, HttpMethod.GET, jwkSetUri);
+			ResponseEntity<String> response = null;
+			try {
+				response = this.restOperations.exchange(request, String.class);
+			} catch (Exception ex) {
+				throwInvalidClient("jwk_set_response_error", ex);
+			}
+			if (response.getStatusCode().value() != 200) {
+				throwInvalidClient("jwk_set_response_status");
+			}
+
+			JWKSet jwkSet = null;
+			try {
+				jwkSet = JWKSet.parse(response.getBody());
+			} catch (ParseException ex) {
+				throwInvalidClient("jwk_set_response_body", ex);
+			}
+
+			return jwkSet;
+		}
+
+		private class JwkSetHolder implements Supplier<JWKSet> {
+			private final String jwkSetUrl;
+			private JWKSet jwkSet;
+
+			private JwkSetHolder(String jwkSetUrl) {
+				this.jwkSetUrl = jwkSetUrl;
+			}
+
+			@Override
+			public JWKSet get() {
+				if (this.jwkSet == null) {
+					this.jwkSet = retrieve(this.jwkSetUrl);
+				}
+				return this.jwkSet;
+			}
+
+		}
+
+	}
+
+}

+ 1 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java

@@ -129,6 +129,7 @@ public final class OidcProviderConfigurationEndpointFilter extends OncePerReques
 			authenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
 			authenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue());
 			authenticationMethods.add("tls_client_auth");
+			authenticationMethods.add("self_signed_tls_client_auth");
 		};
 	}
 

+ 1 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java

@@ -122,6 +122,7 @@ public final class OAuth2AuthorizationServerMetadataEndpointFilter extends OnceP
 			authenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
 			authenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue());
 			authenticationMethods.add("tls_client_auth");
+			authenticationMethods.add("self_signed_tls_client_auth");
 		};
 	}
 

+ 10 - 3
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverter.java

@@ -35,7 +35,7 @@ import org.springframework.util.StringUtils;
 /**
  * Attempts to extract a client {@code X509Certificate} chain from {@link HttpServletRequest}
  * and then converts to an {@link OAuth2ClientAuthenticationToken} used for authenticating the client
- * using the {@code tls_client_auth} method.
+ * using the {@code tls_client_auth} or {@code self_signed_tls_client_auth} method.
  *
  * @author Joe Grandja
  * @since 1.3
@@ -46,13 +46,15 @@ import org.springframework.util.StringUtils;
 public final class X509ClientCertificateAuthenticationConverter implements AuthenticationConverter {
 	private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
 			new ClientAuthenticationMethod("tls_client_auth");
+	private static final ClientAuthenticationMethod SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
+			new ClientAuthenticationMethod("self_signed_tls_client_auth");
 
 	@Nullable
 	@Override
 	public Authentication convert(HttpServletRequest request) {
 		X509Certificate[] clientCertificateChain =
 				(X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate");
-		if (clientCertificateChain == null || clientCertificateChain.length <= 1) {
+		if (clientCertificateChain == null || clientCertificateChain.length == 0) {
 			return null;
 		}
 
@@ -68,7 +70,12 @@ public final class X509ClientCertificateAuthenticationConverter implements Authe
 		Map<String, Object> additionalParameters = OAuth2EndpointUtils.getParametersIfMatchesAuthorizationCodeGrantRequest(
 				request, OAuth2ParameterNames.CLIENT_ID);
 
-		return new OAuth2ClientAuthenticationToken(clientId, TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+		ClientAuthenticationMethod clientAuthenticationMethod =
+				clientCertificateChain.length == 1 ?
+						SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD :
+						TLS_CLIENT_AUTH_AUTHENTICATION_METHOD;
+
+		return new OAuth2ClientAuthenticationToken(clientId, clientAuthenticationMethod,
 				clientCertificateChain, additionalParameters);
 	}
 

+ 233 - 4
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProviderTests.java

@@ -15,12 +15,27 @@
  */
 package org.springframework.security.oauth2.server.authorization.authentication;
 
+import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
-
+import java.util.UUID;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.util.Base64;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
@@ -59,18 +74,47 @@ public class X509ClientCertificateAuthenticationProviderTests {
 	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
 	private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
 			new ClientAuthenticationMethod("tls_client_auth");
+	private static final ClientAuthenticationMethod SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
+			new ClientAuthenticationMethod("self_signed_tls_client_auth");
+	private JWKSet selfSignedCertificateJwkSet;
+	private MockWebServer server;
+	private String clientJwkSetUrl;
 	private RegisteredClientRepository registeredClientRepository;
 	private OAuth2AuthorizationService authorizationService;
 	private X509ClientCertificateAuthenticationProvider authenticationProvider;
 
 	@BeforeEach
-	public void setUp() {
+	public void setUp() throws Exception {
+		// @formatter:off
+		X509Certificate selfSignedCertificate = TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE[0];
+		RSAKey selfSignedRSAKey = new RSAKey.Builder((RSAPublicKey) selfSignedCertificate.getPublicKey())
+				.keyUse(KeyUse.SIGNATURE)
+				.keyID(UUID.randomUUID().toString())
+				.x509CertChain(Collections.singletonList(Base64.encode(selfSignedCertificate.getEncoded())))
+				.build();
+		// @formatter:on
+		this.selfSignedCertificateJwkSet = new JWKSet(selfSignedRSAKey);
+		this.server = new MockWebServer();
+		this.server.start();
+		this.clientJwkSetUrl = this.server.url("/jwks").toString();
+		// @formatter:off
+		MockResponse response = new MockResponse()
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+				.setBody(this.selfSignedCertificateJwkSet.toString());
+		// @formatter:on
+		this.server.enqueue(response);
+
 		this.registeredClientRepository = mock(RegisteredClientRepository.class);
 		this.authorizationService = mock(OAuth2AuthorizationService.class);
 		this.authenticationProvider = new X509ClientCertificateAuthenticationProvider(
 				this.registeredClientRepository, this.authorizationService);
 	}
 
+	@AfterEach
+	public void tearDown() throws Exception {
+		this.server.shutdown();
+	}
+
 	@Test
 	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
 		assertThatThrownBy(() -> new X509ClientCertificateAuthenticationProvider(null, this.authorizationService))
@@ -159,7 +203,7 @@ public class X509ClientCertificateAuthenticationProviderTests {
 	}
 
 	@Test
-	public void authenticateWhenInvalidX509CertificateSubjectDNThenThrowOAuth2AuthenticationException() {
+	public void authenticateWhenPKIX509CertificateInvalidSubjectDNThenThrowOAuth2AuthenticationException() {
 		// @formatter:off
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
 				.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
@@ -186,7 +230,7 @@ public class X509ClientCertificateAuthenticationProviderTests {
 	}
 
 	@Test
-	public void authenticateWhenValidX509CertificateThenAuthenticated() {
+	public void authenticateWhenPKIX509CertificateValidThenAuthenticated() {
 		// @formatter:off
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
 				.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
@@ -214,6 +258,191 @@ public class X509ClientCertificateAuthenticationProviderTests {
 		assertThat(authenticationResult.getClientAuthenticationMethod()).isEqualTo(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD);
 	}
 
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateInvalidIssuerThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl(this.clientJwkSetUrl)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);	// PKI Certificate will have different issuer
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+					assertThat(error.getDescription()).contains("x509_certificate_issuer");
+				});
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateMissingClientJwkSetUrlThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+					assertThat(error.getDescription()).contains("client_jwk_set_url");
+				});
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateInvalidClientJwkSetUrlThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl("https://this is an invalid URL")
+								.build()
+				)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+					assertThat(error.getDescription()).contains("jwk_set_uri");
+				});
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateJwkSetResponseErrorStatusThenThrowOAuth2AuthenticationException() {
+		MockResponse jwkSetResponse = new MockResponse().setResponseCode(400);
+		authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+				jwkSetResponse, "jwk_set_response_error");
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidStatusThenThrowOAuth2AuthenticationException() {
+		MockResponse jwkSetResponse = new MockResponse().setResponseCode(204);
+		authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+				jwkSetResponse, "jwk_set_response_status");
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidContentThenThrowOAuth2AuthenticationException() {
+		MockResponse jwkSetResponse = new MockResponse().setResponseCode(200).setBody("invalid-content");
+		authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+				jwkSetResponse, "jwk_set_response_body");
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateJwkSetResponseNoMatchingKeysThenThrowOAuth2AuthenticationException() throws Exception {
+		// @formatter:off
+		X509Certificate pkiCertificate = TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0];
+		RSAKey pkiRSAKey = new RSAKey.Builder((RSAPublicKey) pkiCertificate.getPublicKey())
+				.keyUse(KeyUse.SIGNATURE)
+				.keyID(UUID.randomUUID().toString())
+				.x509CertChain(Collections.singletonList(Base64.encode(pkiCertificate.getEncoded())))
+				.build();
+		// @formatter:on
+
+		// @formatter:off
+		MockResponse jwkSetResponse = new MockResponse()
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+				.setBody(new JWKSet(pkiRSAKey).toString());
+		// @formatter:on
+
+		authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+				jwkSetResponse, "x509_certificate");
+	}
+
+	private void authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+			final MockResponse jwkSetResponse, String expectedErrorDescription) {
+
+		// @formatter:off
+		final Dispatcher dispatcher = new Dispatcher() {
+			@Override
+			public MockResponse dispatch(RecordedRequest request) {
+				return jwkSetResponse;
+			}
+		};
+		this.server.setDispatcher(dispatcher);
+		// @formatter:on
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl(this.clientJwkSetUrl)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+					assertThat(error.getDescription()).contains(expectedErrorDescription);
+				});
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateValidThenAuthenticated() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl(this.clientJwkSetUrl)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null);
+
+		OAuth2ClientAuthenticationToken authenticationResult =
+				(OAuth2ClientAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials()).isEqualTo(TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getClientAuthenticationMethod()).isEqualTo(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD);
+	}
+
 	@Test
 	public void authenticateWhenPkceAndValidCodeVerifierThenAuthenticated() {
 		// @formatter:off

+ 1 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java

@@ -264,7 +264,7 @@ public class OAuth2ClientCredentialsGrantTests {
 	}
 
 	@Test
-	public void requestWhenTokenRequestWithX509ClientCertificateThenTokenResponse() throws Exception {
+	public void requestWhenTokenRequestWithPKIX509ClientCertificateThenTokenResponse() throws Exception {
 		this.spring.register(AuthorizationServerConfiguration.class).autowire();
 
 		// @formatter:off

+ 3 - 3
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java

@@ -127,15 +127,15 @@ public class OidcProviderConfigurationEndpointFilterTests {
 		assertThat(providerConfigurationResponse).contains("\"response_types_supported\":[\"code\"]");
 		assertThat(providerConfigurationResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\",\"urn:ietf:params:oauth:grant-type:device_code\",\"urn:ietf:params:oauth:grant-type:token-exchange\"]");
 		assertThat(providerConfigurationResponse).contains("\"revocation_endpoint\":\"https://example.com/oauth2/v1/revoke\"");
-		assertThat(providerConfigurationResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]");
+		assertThat(providerConfigurationResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
 		assertThat(providerConfigurationResponse).contains("\"introspection_endpoint\":\"https://example.com/oauth2/v1/introspect\"");
-		assertThat(providerConfigurationResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]");
+		assertThat(providerConfigurationResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
 		assertThat(providerConfigurationResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]");
 		assertThat(providerConfigurationResponse).contains("\"subject_types_supported\":[\"public\"]");
 		assertThat(providerConfigurationResponse).contains("\"id_token_signing_alg_values_supported\":[\"RS256\"]");
 		assertThat(providerConfigurationResponse).contains("\"userinfo_endpoint\":\"https://example.com/userinfo\"");
 		assertThat(providerConfigurationResponse).contains("\"end_session_endpoint\":\"https://example.com/connect/logout\"");
-		assertThat(providerConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]");
+		assertThat(providerConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
 	}
 
 	@Test

+ 3 - 3
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java

@@ -118,14 +118,14 @@ public class OAuth2AuthorizationServerMetadataEndpointFilterTests {
 		assertThat(authorizationServerMetadataResponse).contains("\"issuer\":\"https://example.com\"");
 		assertThat(authorizationServerMetadataResponse).contains("\"authorization_endpoint\":\"https://example.com/oauth2/v1/authorize\"");
 		assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint\":\"https://example.com/oauth2/v1/token\"");
-		assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]");
+		assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
 		assertThat(authorizationServerMetadataResponse).contains("\"jwks_uri\":\"https://example.com/oauth2/v1/jwks\"");
 		assertThat(authorizationServerMetadataResponse).contains("\"response_types_supported\":[\"code\"]");
 		assertThat(authorizationServerMetadataResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\",\"urn:ietf:params:oauth:grant-type:device_code\",\"urn:ietf:params:oauth:grant-type:token-exchange\"]");
 		assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint\":\"https://example.com/oauth2/v1/revoke\"");
-		assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]");
+		assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
 		assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint\":\"https://example.com/oauth2/v1/introspect\"");
-		assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]");
+		assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
 		assertThat(authorizationServerMetadataResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]");
 	}
 

+ 26 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverterTests.java

@@ -15,6 +15,8 @@
  */
 package org.springframework.security.oauth2.server.authorization.web.authentication;
 
+import java.security.cert.X509Certificate;
+
 import org.junit.jupiter.api.Test;
 
 import org.springframework.mock.web.MockHttpServletRequest;
@@ -46,10 +48,10 @@ public class X509ClientCertificateAuthenticationConverterTests {
 	}
 
 	@Test
-	public void convertWhenSelfSignedX509CertificateThenReturnNull() {
+	public void convertWhenEmptyX509CertificateThenReturnNull() {
 		MockHttpServletRequest request = new MockHttpServletRequest();
 		request.setAttribute("jakarta.servlet.request.X509Certificate",
-				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE);
+				new X509Certificate[0]);
 		Authentication authentication = this.converter.convert(request);
 		assertThat(authentication).isNull();
 	}
@@ -102,4 +104,26 @@ public class X509ClientCertificateAuthenticationConverterTests {
 						entry("custom-param-2", new String[] {"custom-value-1", "custom-value-2"}));
 	}
 
+	@Test
+	public void convertWhenSelfSignedX509CertificateThenReturnClientAuthenticationToken() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setAttribute("jakarta.servlet.request.X509Certificate",
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-1");
+		request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		request.addParameter(OAuth2ParameterNames.CODE, "code");
+		request.addParameter("custom-param-1", "custom-value-1");
+		request.addParameter("custom-param-2", "custom-value-1", "custom-value-2");
+		OAuth2ClientAuthenticationToken authentication = (OAuth2ClientAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication.getPrincipal()).isEqualTo("client-1");
+		assertThat(authentication.getCredentials()).isEqualTo(TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE);
+		assertThat(authentication.getClientAuthenticationMethod().getValue()).isEqualTo("self_signed_tls_client_auth");
+		assertThat(authentication.getAdditionalParameters())
+				.containsOnly(
+						entry(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()),
+						entry(OAuth2ParameterNames.CODE, "code"),
+						entry("custom-param-1", "custom-value-1"),
+						entry("custom-param-2", new String[] {"custom-value-1", "custom-value-2"}));
+	}
+
 }