Browse Source

Add PKI Mutual-TLS client authentication method

Issue gh-101

Closes gh-1558
Joe Grandja 1 year ago
parent
commit
682c1f936e
19 changed files with 1081 additions and 15 deletions
  1. 2 0
      dependencies/spring-authorization-server-dependencies.gradle
  2. 2 0
      oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle
  3. 107 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationContext.java
  4. 167 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProvider.java
  5. 7 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java
  6. 1 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java
  7. 24 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettings.java
  8. 8 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java
  9. 1 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java
  10. 6 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2ClientAuthenticationFilter.java
  11. 75 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverter.java
  12. 275 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProviderTests.java
  13. 39 3
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java
  14. 4 4
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java
  15. 9 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java
  16. 69 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/TestX509Certificates.java
  17. 176 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/X509CertificateUtils.java
  18. 4 4
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java
  19. 105 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverterTests.java

+ 2 - 0
dependencies/spring-authorization-server-dependencies.gradle

@@ -13,6 +13,8 @@ dependencies {
 	constraints {
 		api "com.nimbusds:nimbus-jose-jwt:9.37.3"
 		api "jakarta.servlet:jakarta.servlet-api:6.0.0"
+		api "org.bouncycastle:bcpkix-jdk18on:1.77"
+		api "org.bouncycastle:bcprov-jdk18on:1.77"
 		api "org.junit.jupiter:junit-jupiter:5.10.1"
 		api "org.assertj:assertj-core:3.25.1"
 		api "org.mockito:mockito-core:4.11.0"

+ 2 - 0
oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle

@@ -21,6 +21,8 @@ dependencies {
 
 	testImplementation "org.springframework.security:spring-security-test"
 	testImplementation "org.springframework:spring-webmvc"
+	testImplementation "org.bouncycastle:bcpkix-jdk18on"
+	testImplementation "org.bouncycastle:bcprov-jdk18on"
 	testImplementation "org.junit.jupiter:junit-jupiter"
 	testImplementation "org.assertj:assertj-core"
 	testImplementation "org.mockito:mockito-core"

+ 107 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationContext.java

@@ -0,0 +1,107 @@
+/*
+ * 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.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link OAuth2AuthenticationContext} that holds an {@link OAuth2ClientAuthenticationToken} and additional information
+ * and is used when validating an OAuth 2.0 Client Authentication.
+ *
+ * @author Joe Grandja
+ * @since 1.3
+ * @see OAuth2AuthenticationContext
+ * @see OAuth2ClientAuthenticationToken
+ * @see X509ClientCertificateAuthenticationProvider#setCertificateVerifier(Consumer)
+ */
+public final class OAuth2ClientAuthenticationContext implements OAuth2AuthenticationContext {
+	private final Map<Object, Object> context;
+
+	private OAuth2ClientAuthenticationContext(Map<Object, Object> context) {
+		this.context = Collections.unmodifiableMap(new HashMap<>(context));
+	}
+
+	@SuppressWarnings("unchecked")
+	@Nullable
+	@Override
+	public <V> V get(Object key) {
+		return hasKey(key) ? (V) this.context.get(key) : null;
+	}
+
+	@Override
+	public boolean hasKey(Object key) {
+		Assert.notNull(key, "key cannot be null");
+		return this.context.containsKey(key);
+	}
+
+	/**
+	 * Returns the {@link RegisteredClient registered client}.
+	 *
+	 * @return the {@link RegisteredClient}
+	 */
+	public RegisteredClient getRegisteredClient() {
+		return get(RegisteredClient.class);
+	}
+
+	/**
+	 * Constructs a new {@link Builder} with the provided {@link OAuth2ClientAuthenticationToken}.
+	 *
+	 * @param authentication the {@link OAuth2ClientAuthenticationToken}
+	 * @return the {@link Builder}
+	 */
+	public static Builder with(OAuth2ClientAuthenticationToken authentication) {
+		return new Builder(authentication);
+	}
+
+	/**
+	 * A builder for {@link OAuth2ClientAuthenticationContext}.
+	 */
+	public static final class Builder extends AbstractBuilder<OAuth2ClientAuthenticationContext, Builder> {
+
+		private Builder(OAuth2ClientAuthenticationToken authentication) {
+			super(authentication);
+		}
+
+		/**
+		 * Sets the {@link RegisteredClient registered client}.
+		 *
+		 * @param registeredClient the {@link RegisteredClient}
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder registeredClient(RegisteredClient registeredClient) {
+			return put(RegisteredClient.class, registeredClient);
+		}
+
+		/**
+		 * Builds a new {@link OAuth2ClientAuthenticationContext}.
+		 *
+		 * @return the {@link OAuth2ClientAuthenticationContext}
+		 */
+		public OAuth2ClientAuthenticationContext build() {
+			Assert.notNull(get(RegisteredClient.class), "registeredClient cannot be null");
+			return new OAuth2ClientAuthenticationContext(getContext());
+		}
+
+	}
+
+}

+ 167 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProvider.java

@@ -0,0 +1,167 @@
+/*
+ * 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.security.cert.X509Certificate;
+import java.util.function.Consumer;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+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.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.util.Assert;
+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.
+ *
+ * @author Joe Grandja
+ * @since 1.3
+ * @see AuthenticationProvider
+ * @see OAuth2ClientAuthenticationToken
+ * @see RegisteredClientRepository
+ * @see OAuth2AuthorizationService
+ */
+public final class X509ClientCertificateAuthenticationProvider implements AuthenticationProvider {
+	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 final Log logger = LogFactory.getLog(getClass());
+	private final RegisteredClientRepository registeredClientRepository;
+	private final CodeVerifierAuthenticator codeVerifierAuthenticator;
+	private Consumer<OAuth2ClientAuthenticationContext> certificateVerifier = this::verifyX509CertificateSubjectDN;
+
+	/**
+	 * Constructs a {@code X509ClientCertificateAuthenticationProvider} using the provided parameters.
+	 *
+	 * @param registeredClientRepository the repository of registered clients
+	 * @param authorizationService the authorization service
+	 */
+	public X509ClientCertificateAuthenticationProvider(RegisteredClientRepository registeredClientRepository,
+			OAuth2AuthorizationService authorizationService) {
+		Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
+		Assert.notNull(authorizationService, "authorizationService cannot be null");
+		this.registeredClientRepository = registeredClientRepository;
+		this.codeVerifierAuthenticator = new CodeVerifierAuthenticator(authorizationService);
+	}
+
+	@Override
+	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+		OAuth2ClientAuthenticationToken clientAuthentication =
+				(OAuth2ClientAuthenticationToken) authentication;
+
+		if (!TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) {
+			return null;
+		}
+
+		String clientId = clientAuthentication.getPrincipal().toString();
+		RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
+		if (registeredClient == null) {
+			throwInvalidClient(OAuth2ParameterNames.CLIENT_ID);
+		}
+
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace("Retrieved registered client");
+		}
+
+		if (!registeredClient.getClientAuthenticationMethods().contains(
+				clientAuthentication.getClientAuthenticationMethod())) {
+			throwInvalidClient("authentication_method");
+		}
+
+		if (!(clientAuthentication.getCredentials() instanceof X509Certificate[])) {
+			throwInvalidClient("credentials");
+		}
+
+		OAuth2ClientAuthenticationContext authenticationContext =
+				OAuth2ClientAuthenticationContext.with(clientAuthentication)
+						.registeredClient(registeredClient)
+						.build();
+		this.certificateVerifier.accept(authenticationContext);
+
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace("Validated client authentication parameters");
+		}
+
+		// Validate the "code_verifier" parameter for the confidential client, if available
+		this.codeVerifierAuthenticator.authenticateIfAvailable(clientAuthentication, registeredClient);
+
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace("Authenticated client X509Certificate");
+		}
+
+		return new OAuth2ClientAuthenticationToken(registeredClient,
+				clientAuthentication.getClientAuthenticationMethod(), clientAuthentication.getCredentials());
+	}
+
+	@Override
+	public boolean supports(Class<?> authentication) {
+		return OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication);
+	}
+
+	/**
+	 * 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}.
+	 *
+	 * <p>
+	 * <b>NOTE:</b> If verification fails, an {@link OAuth2AuthenticationException} MUST be thrown.
+	 *
+	 * @param certificateVerifier the {@code Consumer} providing access to the {@link OAuth2ClientAuthenticationContext} and is responsible for verifying the client {@code X509Certificate}
+	 */
+	public void setCertificateVerifier(Consumer<OAuth2ClientAuthenticationContext> certificateVerifier) {
+		Assert.notNull(certificateVerifier, "certificateVerifier cannot be null");
+		this.certificateVerifier = certificateVerifier;
+	}
+
+	private void verifyX509CertificateSubjectDN(OAuth2ClientAuthenticationContext clientAuthenticationContext) {
+		OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication();
+		RegisteredClient registeredClient = clientAuthenticationContext.getRegisteredClient();
+		X509Certificate[] clientCertificateChain = (X509Certificate[]) clientAuthentication.getCredentials();
+		X509Certificate clientCertificate = clientCertificateChain[0];
+		String expectedSubjectDN = registeredClient.getClientSettings().getX509CertificateSubjectDN();
+		if (!StringUtils.hasText(expectedSubjectDN) ||
+				!clientCertificate.getSubjectX500Principal().getName().equals(expectedSubjectDN)) {
+			throwInvalidClient("x509_certificate_subject_dn");
+		}
+	}
+
+	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);
+	}
+
+}

+ 7 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java

@@ -34,6 +34,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.C
 import org.springframework.security.oauth2.server.authorization.authentication.JwtClientAssertionAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
 import org.springframework.security.oauth2.server.authorization.authentication.PublicClientAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.X509ClientCertificateAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
 import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter;
@@ -42,6 +43,7 @@ import org.springframework.security.oauth2.server.authorization.web.authenticati
 import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.JwtClientAssertionAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.PublicClientAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.X509ClientCertificateAuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
@@ -214,6 +216,7 @@ public final class OAuth2ClientAuthenticationConfigurer extends AbstractOAuth2Co
 		List<AuthenticationConverter> authenticationConverters = new ArrayList<>();
 
 		authenticationConverters.add(new JwtClientAssertionAuthenticationConverter());
+		authenticationConverters.add(new X509ClientCertificateAuthenticationConverter());
 		authenticationConverters.add(new ClientSecretBasicAuthenticationConverter());
 		authenticationConverters.add(new ClientSecretPostAuthenticationConverter());
 		authenticationConverters.add(new PublicClientAuthenticationConverter());
@@ -231,6 +234,10 @@ public final class OAuth2ClientAuthenticationConfigurer extends AbstractOAuth2Co
 				new JwtClientAssertionAuthenticationProvider(registeredClientRepository, authorizationService);
 		authenticationProviders.add(jwtClientAssertionAuthenticationProvider);
 
+		X509ClientCertificateAuthenticationProvider x509ClientCertificateAuthenticationProvider =
+				new X509ClientCertificateAuthenticationProvider(registeredClientRepository, authorizationService);
+		authenticationProviders.add(x509ClientCertificateAuthenticationProvider);
+
 		ClientSecretAuthenticationProvider clientSecretAuthenticationProvider =
 				new ClientSecretAuthenticationProvider(registeredClientRepository, authorizationService);
 		PasswordEncoder passwordEncoder = OAuth2ConfigurerUtils.getOptionalBean(httpSecurity, PasswordEncoder.class);

+ 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_POST.getValue());
 			authenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
 			authenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue());
+			authenticationMethods.add("tls_client_auth");
 		};
 	}
 

+ 24 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettings.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * 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.
@@ -78,6 +78,17 @@ public final class ClientSettings extends AbstractSettings {
 		return getSetting(ConfigurationSettingNames.Client.TOKEN_ENDPOINT_AUTHENTICATION_SIGNING_ALGORITHM);
 	}
 
+	/**
+	 * Returns the expected subject distinguished name associated to the client {@code X509Certificate}
+	 * received during client authentication when using the {@code tls_client_auth} method.
+	 *
+	 * @return the expected subject distinguished name associated to the client {@code X509Certificate} received during client authentication
+	 * @since 1.3
+	 */
+	public String getX509CertificateSubjectDN() {
+		return getSetting(ConfigurationSettingNames.Client.X509_CERTIFICATE_SUBJECT_DN);
+	}
+
 	/**
 	 * Constructs a new {@link Builder} with the default settings.
 	 *
@@ -156,6 +167,18 @@ public final class ClientSettings extends AbstractSettings {
 			return setting(ConfigurationSettingNames.Client.TOKEN_ENDPOINT_AUTHENTICATION_SIGNING_ALGORITHM, authenticationSigningAlgorithm);
 		}
 
+		/**
+		 * Sets the expected subject distinguished name associated to the client {@code X509Certificate}
+		 * received during client authentication when using the {@code tls_client_auth} method.
+		 *
+		 * @param x509CertificateSubjectDN the expected subject distinguished name associated to the client {@code X509Certificate} received during client authentication		 * @return the {@link Builder} for further configuration
+		 * @return the {@link Builder} for further configuration
+		 * @since 1.3
+		 */
+		public Builder x509CertificateSubjectDN(String x509CertificateSubjectDN) {
+			return setting(ConfigurationSettingNames.Client.X509_CERTIFICATE_SUBJECT_DN, x509CertificateSubjectDN);
+		}
+
 		/**
 		 * Builds the {@link ClientSettings}.
 		 *

+ 8 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2023 the original author or authors.
+ * 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.
@@ -65,6 +65,13 @@ public final class ConfigurationSettingNames {
 		 */
 		public static final String TOKEN_ENDPOINT_AUTHENTICATION_SIGNING_ALGORITHM = CLIENT_SETTINGS_NAMESPACE.concat("token-endpoint-authentication-signing-algorithm");
 
+		/**
+		 * Set the expected subject distinguished name associated to the client {@code X509Certificate}
+		 * received during client authentication when using the {@code tls_client_auth} method.
+		 * @since 1.3
+		 */
+		public static final String X509_CERTIFICATE_SUBJECT_DN = CLIENT_SETTINGS_NAMESPACE.concat("x509-certificate-subject-dn");
+
 		private Client() {
 		}
 

+ 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_POST.getValue());
 			authenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
 			authenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue());
+			authenticationMethods.add("tls_client_auth");
 		};
 	}
 

+ 6 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2ClientAuthenticationFilter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * 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.
@@ -42,11 +42,13 @@ import org.springframework.security.oauth2.server.authorization.authentication.C
 import org.springframework.security.oauth2.server.authorization.authentication.JwtClientAssertionAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
 import org.springframework.security.oauth2.server.authorization.authentication.PublicClientAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.X509ClientCertificateAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.web.authentication.ClientSecretBasicAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.ClientSecretPostAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.JwtClientAssertionAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.PublicClientAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.X509ClientCertificateAuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
@@ -64,6 +66,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
  * @see AuthenticationManager
  * @see JwtClientAssertionAuthenticationConverter
  * @see JwtClientAssertionAuthenticationProvider
+ * @see X509ClientCertificateAuthenticationConverter
+ * @see X509ClientCertificateAuthenticationProvider
  * @see ClientSecretBasicAuthenticationConverter
  * @see ClientSecretPostAuthenticationConverter
  * @see ClientSecretAuthenticationProvider
@@ -97,6 +101,7 @@ public final class OAuth2ClientAuthenticationFilter extends OncePerRequestFilter
 		this.authenticationConverter = new DelegatingAuthenticationConverter(
 				Arrays.asList(
 						new JwtClientAssertionAuthenticationConverter(),
+						new X509ClientCertificateAuthenticationConverter(),
 						new ClientSecretBasicAuthenticationConverter(),
 						new ClientSecretPostAuthenticationConverter(),
 						new PublicClientAuthenticationConverter()));

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

@@ -0,0 +1,75 @@
+/*
+ * 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.web.authentication;
+
+import java.security.cert.X509Certificate;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.util.MultiValueMap;
+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.
+ *
+ * @author Joe Grandja
+ * @since 1.3
+ * @see AuthenticationConverter
+ * @see OAuth2ClientAuthenticationToken
+ * @see OAuth2ClientAuthenticationFilter
+ */
+public final class X509ClientCertificateAuthenticationConverter implements AuthenticationConverter {
+	private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
+			new ClientAuthenticationMethod("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) {
+			return null;
+		}
+
+		MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getFormParameters(request);
+
+		// client_id (REQUIRED)
+		String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID);
+		if (!StringUtils.hasText(clientId) ||
+				parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) {
+			throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
+		}
+
+		Map<String, Object> additionalParameters = OAuth2EndpointUtils.getParametersIfMatchesAuthorizationCodeGrantRequest(
+				request, OAuth2ParameterNames.CLIENT_ID);
+
+		return new OAuth2ClientAuthenticationToken(clientId, TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				clientCertificateChain, additionalParameters);
+	}
+
+}

+ 275 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProviderTests.java

@@ -0,0 +1,275 @@
+/*
+ * 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.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.util.TestX509Certificates;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link X509ClientCertificateAuthenticationProvider}.
+ *
+ * @author Joe Grandja
+ */
+public class X509ClientCertificateAuthenticationProviderTests {
+	// See RFC 7636: Appendix B.  Example for the S256 code_challenge_method
+	// https://tools.ietf.org/html/rfc7636#appendix-B
+	private static final String S256_CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+	private static final String S256_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+	private static final String AUTHORIZATION_CODE = "code";
+	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 RegisteredClientRepository registeredClientRepository;
+	private OAuth2AuthorizationService authorizationService;
+	private X509ClientCertificateAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new X509ClientCertificateAuthenticationProvider(
+				this.registeredClientRepository, this.authorizationService);
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new X509ClientCertificateAuthenticationProvider(null, this.authorizationService))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new X509ClientCertificateAuthenticationProvider(this.registeredClientRepository, null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void setCertificateVerifierWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setCertificateVerifier(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("certificateVerifier cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2ClientAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2ClientAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId() + "-invalid", TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_PKI_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(OAuth2ParameterNames.CLIENT_ID);
+				});
+	}
+
+	@Test
+	public void authenticateWhenUnsupportedClientAuthenticationMethodThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_PKI_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("authentication_method");
+				});
+	}
+
+	@Test
+	public void authenticateWhenX509CertificateNotProvidedThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD, null, 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("credentials");
+				});
+	}
+
+	@Test
+	public void authenticateWhenInvalidX509CertificateSubjectDNThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.clientSettings(
+						ClientSettings.builder()
+								.x509CertificateSubjectDN("CN=demo-client-sample-2,OU=Spring Samples,O=Spring,C=US")
+								.build()
+				)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_PKI_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("x509_certificate_subject_dn");
+				});
+	}
+
+	@Test
+	public void authenticateWhenValidX509CertificateThenAuthenticated() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.clientSettings(
+						ClientSettings.builder()
+								.x509CertificateSubjectDN(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0].getSubjectX500Principal().getName())
+								.build()
+				)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_PKI_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_PKI_CERTIFICATE);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getClientAuthenticationMethod()).isEqualTo(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD);
+	}
+
+	@Test
+	public void authenticateWhenPkceAndValidCodeVerifierThenAuthenticated() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
+				.clientSettings(
+						ClientSettings.builder()
+								.x509CertificateSubjectDN(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0].getSubjectX500Principal().getName())
+								.build()
+				)
+				.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+				.authorization(registeredClient, createPkceAuthorizationParametersS256())
+				.build();
+		when(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+				.thenReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, parameters);
+
+		OAuth2ClientAuthenticationToken authenticationResult =
+				(OAuth2ClientAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE));
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials()).isEqualTo(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getClientAuthenticationMethod()).isEqualTo(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD);
+	}
+
+	private static Map<String, Object> createPkceAuthorizationParametersS256() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
+		parameters.put(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE);
+		return parameters;
+	}
+
+	private static Map<String, Object> createPkceTokenParameters(String codeVerifier) {
+		Map<String, Object> parameters = createAuthorizationCodeTokenParameters();
+		parameters.put(PkceParameterNames.CODE_VERIFIER, codeVerifier);
+		return parameters;
+	}
+
+	private static Map<String, Object> createAuthorizationCodeTokenParameters() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		parameters.put(OAuth2ParameterNames.CODE, AUTHORIZATION_CODE);
+		return parameters;
+	}
+
+}

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

@@ -24,12 +24,13 @@ import java.util.Base64;
 import java.util.List;
 import java.util.function.Consumer;
 
-import com.nimbusds.jose.jwk.JWKSet;
-import com.nimbusds.jose.jwk.source.JWKSource;
-import com.nimbusds.jose.proc.SecurityContext;
 import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
@@ -75,6 +76,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.O
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.PublicClientAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.X509ClientCertificateAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
 import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
@@ -82,10 +84,12 @@ import org.springframework.security.oauth2.server.authorization.client.Registere
 import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
 import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
 import org.springframework.security.oauth2.server.authorization.jackson2.TestingAuthenticationTokenMixin;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
 import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
 import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
 import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
 import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.security.oauth2.server.authorization.util.TestX509Certificates;
 import org.springframework.security.oauth2.server.authorization.web.authentication.ClientSecretBasicAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.ClientSecretPostAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.JwtClientAssertionAuthenticationConverter;
@@ -95,6 +99,7 @@ import org.springframework.security.oauth2.server.authorization.web.authenticati
 import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2TokenExchangeAuthenticationConverter;
 import org.springframework.security.oauth2.server.authorization.web.authentication.PublicClientAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.X509ClientCertificateAuthenticationConverter;
 import org.springframework.security.web.SecurityFilterChain;
 import org.springframework.security.web.authentication.AuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
@@ -111,6 +116,7 @@ import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.x509;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -257,6 +263,34 @@ public class OAuth2ClientCredentialsGrantTests {
 		assertThat(updatedRegisteredClient.getClientSecret()).startsWith("{bcrypt}");
 	}
 
+	@Test
+	public void requestWhenTokenRequestWithX509ClientCertificateThenTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2()
+				.clientAuthenticationMethod(new ClientAuthenticationMethod("tls_client_auth"))
+				.clientSettings(
+						ClientSettings.builder()
+								.x509CertificateSubjectDN(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0].getSubjectX500Principal().getName())
+								.build()
+				)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+						.with(x509(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE))
+						.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+						.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+						.param(OAuth2ParameterNames.SCOPE, "scope1 scope2"))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.access_token").isNotEmpty())
+				.andExpect(jsonPath("$.scope").value("scope1 scope2"));
+
+		verify(jwtCustomizer).customize(any());
+	}
+
 	@Test
 	public void requestWhenTokenEndpointCustomizedThenUsed() throws Exception {
 		this.spring.register(AuthorizationServerConfigurationCustomTokenEndpoint.class).autowire();
@@ -341,6 +375,7 @@ public class OAuth2ClientCredentialsGrantTests {
 		assertThat(authenticationConverters).allMatch((converter) ->
 				converter == authenticationConverter ||
 						converter instanceof JwtClientAssertionAuthenticationConverter ||
+						converter instanceof X509ClientCertificateAuthenticationConverter ||
 						converter instanceof ClientSecretBasicAuthenticationConverter ||
 						converter instanceof ClientSecretPostAuthenticationConverter ||
 						converter instanceof PublicClientAuthenticationConverter);
@@ -354,6 +389,7 @@ public class OAuth2ClientCredentialsGrantTests {
 		assertThat(authenticationProviders).allMatch((provider) ->
 				provider == authenticationProvider ||
 						provider instanceof JwtClientAssertionAuthenticationProvider ||
+						provider instanceof X509ClientCertificateAuthenticationProvider ||
 						provider instanceof ClientSecretAuthenticationProvider ||
 						provider instanceof PublicClientAuthenticationProvider);
 

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

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2023 the original author or authors.
+ * 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.
@@ -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\"]");
+		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("\"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\"]");
+		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("\"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\"]");
+		assertThat(providerConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]");
 	}
 
 	@Test

+ 9 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * 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.
@@ -68,6 +68,14 @@ public class ClientSettingsTests {
 		assertThat(clientSettings.getJwkSetUrl()).isEqualTo("https://client.example.com/jwks");
 	}
 
+	@Test
+	public void x509CertificateSubjectDNWhenProvidedThenSet() {
+		ClientSettings clientSettings = ClientSettings.builder()
+				.x509CertificateSubjectDN("CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US")
+				.build();
+		assertThat(clientSettings.getX509CertificateSubjectDN()).isEqualTo("CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US");
+	}
+
 	@Test
 	public void settingWhenCustomThenSet() {
 		ClientSettings clientSettings = ClientSettings.builder()

+ 69 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/TestX509Certificates.java

@@ -0,0 +1,69 @@
+/*
+ * 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.util;
+
+import java.security.KeyPair;
+import java.security.cert.X509Certificate;
+
+/**
+ * @author Joe Grandja
+ */
+public final class TestX509Certificates {
+
+	public static final X509Certificate[] DEMO_CLIENT_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 demo-client-sample
+			KeyPair demoClientKeyPair = X509CertificateUtils.generateRSAKeyPair();
+			distinguishedName = "CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US";
+			X509Certificate demoClientCertificate = X509CertificateUtils.createEndEntityCertificate(
+					caCertificate, caKeyPair.getPrivate(), demoClientKeyPair.getPublic(), distinguishedName);
+
+			DEMO_CLIENT_PKI_CERTIFICATE = new X509Certificate[] { demoClientCertificate, caCertificate, rootCertificate };
+		} catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	public static final X509Certificate[] DEMO_CLIENT_SELF_SIGNED_CERTIFICATE;
+	static {
+		try {
+			// Generate self-signed certificate for demo-client-sample
+			KeyPair keyPair = X509CertificateUtils.generateRSAKeyPair();
+			String distinguishedName = "CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US";
+			X509Certificate demoClientSelfSignedCertificate = X509CertificateUtils.createTrustAnchorCertificate(keyPair, distinguishedName);
+
+			DEMO_CLIENT_SELF_SIGNED_CERTIFICATE = new X509Certificate[] { demoClientSelfSignedCertificate };
+		} catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	private TestX509Certificates() {
+	}
+
+}

+ 176 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/X509CertificateUtils.java

@@ -0,0 +1,176 @@
+/*
+ * 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.util;
+
+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
+ */
+public 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() {
+	}
+
+	public 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;
+	}
+
+	public 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));
+	}
+
+	public 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));
+	}
+
+	public 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));
+	}
+
+}

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

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2023 the original author or authors.
+ * 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.
@@ -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\"]");
+		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("\"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\"]");
+		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("\"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\"]");
+		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("\"code_challenge_methods_supported\":[\"S256\"]");
 	}
 

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

@@ -0,0 +1,105 @@
+/*
+ * 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.web.authentication;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.util.TestX509Certificates;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.entry;
+
+/**
+ * Tests for {@link X509ClientCertificateAuthenticationConverter}.
+ *
+ * @author Joe Grandja
+ */
+public class X509ClientCertificateAuthenticationConverterTests {
+	private final X509ClientCertificateAuthenticationConverter converter = new X509ClientCertificateAuthenticationConverter();
+
+	@Test
+	public void convertWhenMissingX509CertificateThenReturnNull() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		Authentication authentication = this.converter.convert(request);
+		assertThat(authentication).isNull();
+	}
+
+	@Test
+	public void convertWhenSelfSignedX509CertificateThenReturnNull() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setAttribute("jakarta.servlet.request.X509Certificate",
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE);
+		Authentication authentication = this.converter.convert(request);
+		assertThat(authentication).isNull();
+	}
+
+	@Test
+	public void convertWhenMissingClientIdThenInvalidRequestError() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setAttribute("jakarta.servlet.request.X509Certificate",
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE);
+		assertThatThrownBy(() -> this.converter.convert(request))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.extracting("errorCode")
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+	}
+
+	@Test
+	public void convertWhenMultipleClientIdThenInvalidRequestError() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setAttribute("jakarta.servlet.request.X509Certificate",
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-1");
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-2");
+		assertThatThrownBy(() -> this.converter.convert(request))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.extracting("errorCode")
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+	}
+
+	@Test
+	public void convertWhenPkiX509CertificateThenReturnClientAuthenticationToken() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setAttribute("jakarta.servlet.request.X509Certificate",
+				TestX509Certificates.DEMO_CLIENT_PKI_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_PKI_CERTIFICATE);
+		assertThat(authentication.getClientAuthenticationMethod().getValue()).isEqualTo("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"}));
+	}
+
+}