From 682c1f936e6e4de2f720225170eacbfb27ae3fee Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Tue, 12 Mar 2024 07:34:33 -0400 Subject: [PATCH] Add PKI Mutual-TLS client authentication method Issue gh-101 Closes gh-1558 --- ...g-authorization-server-dependencies.gradle | 2 + ...ecurity-oauth2-authorization-server.gradle | 2 + .../OAuth2ClientAuthenticationContext.java | 107 +++++++ ...ientCertificateAuthenticationProvider.java | 167 +++++++++++ .../OAuth2ClientAuthenticationConfigurer.java | 7 + ...dcProviderConfigurationEndpointFilter.java | 1 + .../settings/ClientSettings.java | 25 +- .../settings/ConfigurationSettingNames.java | 9 +- ...orizationServerMetadataEndpointFilter.java | 1 + .../web/OAuth2ClientAuthenticationFilter.java | 7 +- ...entCertificateAuthenticationConverter.java | 75 +++++ ...ertificateAuthenticationProviderTests.java | 275 ++++++++++++++++++ .../OAuth2ClientCredentialsGrantTests.java | 42 ++- ...viderConfigurationEndpointFilterTests.java | 8 +- .../settings/ClientSettingsTests.java | 10 +- .../util/TestX509Certificates.java | 69 +++++ .../util/X509CertificateUtils.java | 176 +++++++++++ ...tionServerMetadataEndpointFilterTests.java | 8 +- ...rtificateAuthenticationConverterTests.java | 105 +++++++ 19 files changed, 1081 insertions(+), 15 deletions(-) create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationContext.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProvider.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverter.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProviderTests.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/TestX509Certificates.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/X509CertificateUtils.java create mode 100644 oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverterTests.java diff --git a/dependencies/spring-authorization-server-dependencies.gradle b/dependencies/spring-authorization-server-dependencies.gradle index 9fa8509f..807f2d81 100644 --- a/dependencies/spring-authorization-server-dependencies.gradle +++ b/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" diff --git a/oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle b/oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle index 52876676..93d30087 100644 --- a/oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle +++ b/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" diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationContext.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationContext.java new file mode 100644 index 00000000..93201f58 --- /dev/null +++ b/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 context; + + private OAuth2ClientAuthenticationContext(Map context) { + this.context = Collections.unmodifiableMap(new HashMap<>(context)); + } + + @SuppressWarnings("unchecked") + @Nullable + @Override + public 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 { + + 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()); + } + + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProvider.java new file mode 100644 index 00000000..025a51f1 --- /dev/null +++ b/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 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}. + * + *

+ * NOTE: 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 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); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java index b4bc1f0e..90559d36 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java +++ b/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 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); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java index 6135efeb..c8e7b868 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java +++ b/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"); }; } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettings.java index 131010b0..0bf00e6e 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettings.java +++ b/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}. * diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java index c51e545a..5b35f5b3 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java +++ b/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() { } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java index 7595bcc4..7f10c809 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilter.java +++ b/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"); }; } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2ClientAuthenticationFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2ClientAuthenticationFilter.java index 6926314d..e90fb298 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2ClientAuthenticationFilter.java +++ b/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())); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverter.java new file mode 100644 index 00000000..d5d617c7 --- /dev/null +++ b/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 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 additionalParameters = OAuth2EndpointUtils.getParametersIfMatchesAuthorizationCodeGrantRequest( + request, OAuth2ParameterNames.CLIENT_ID); + + return new OAuth2ClientAuthenticationToken(clientId, TLS_CLIENT_AUTH_AUTHENTICATION_METHOD, + clientCertificateChain, additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProviderTests.java new file mode 100644 index 00000000..778d9372 --- /dev/null +++ b/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 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 createPkceAuthorizationParametersS256() { + Map parameters = new HashMap<>(); + parameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256"); + parameters.put(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE); + return parameters; + } + + private static Map createPkceTokenParameters(String codeVerifier) { + Map parameters = createAuthorizationCodeTokenParameters(); + parameters.put(PkceParameterNames.CODE_VERIFIER, codeVerifier); + return parameters; + } + + private static Map createAuthorizationCodeTokenParameters() { + Map parameters = new HashMap<>(); + parameters.put(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + parameters.put(OAuth2ParameterNames.CODE, AUTHORIZATION_CODE); + return parameters; + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java index de5cb290..fb459888 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java +++ b/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); diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java index 3c41c9d0..b6105294 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java +++ b/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 diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java index 06362275..f77b08e9 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java +++ b/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() diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/TestX509Certificates.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/TestX509Certificates.java new file mode 100644 index 00000000..f3d14f42 --- /dev/null +++ b/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() { + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/X509CertificateUtils.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/X509CertificateUtils.java new file mode 100644 index 00000000..56ecca8a --- /dev/null +++ b/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)); + } + +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java index d7491da0..74ea6df8 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationServerMetadataEndpointFilterTests.java +++ b/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\"]"); } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverterTests.java new file mode 100644 index 00000000..12457613 --- /dev/null +++ b/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"})); + } + +}