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 index 025a51f10..9987e0aa7 100644 --- 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 @@ -38,7 +38,8 @@ /** * An {@link AuthenticationProvider} implementation used for OAuth 2.0 Client Authentication, - * which authenticates the client {@code X509Certificate} received when the {@code tls_client_auth} authentication method is used. + * which authenticates the client {@code X509Certificate} received + * when the {@code tls_client_auth} or {@code self_signed_tls_client_auth} authentication method is used. * * @author Joe Grandja * @since 1.3 @@ -51,10 +52,14 @@ public final class X509ClientCertificateAuthenticationProvider implements Authen private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1"; private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD = new ClientAuthenticationMethod("tls_client_auth"); + private static final ClientAuthenticationMethod SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD = + new ClientAuthenticationMethod("self_signed_tls_client_auth"); private final Log logger = LogFactory.getLog(getClass()); private final RegisteredClientRepository registeredClientRepository; private final CodeVerifierAuthenticator codeVerifierAuthenticator; - private Consumer certificateVerifier = this::verifyX509CertificateSubjectDN; + private final Consumer selfSignedCertificateVerifier = + new X509SelfSignedCertificateVerifier(); + private Consumer certificateVerifier = this::verifyX509Certificate; /** * Constructs a {@code X509ClientCertificateAuthenticationProvider} using the provided parameters. @@ -75,7 +80,8 @@ public Authentication authenticate(Authentication authentication) throws Authent OAuth2ClientAuthenticationToken clientAuthentication = (OAuth2ClientAuthenticationToken) authentication; - if (!TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) { + if (!TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod()) && + !SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) { return null; } @@ -127,7 +133,8 @@ public boolean supports(Class 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}. + * The default implementation for the {@code tls_client_auth} authentication method + * verifies the {@link ClientSettings#getX509CertificateSubjectDN() expected subject distinguished name}. * *

* NOTE: If verification fails, an {@link OAuth2AuthenticationException} MUST be thrown. @@ -139,6 +146,15 @@ public void setCertificateVerifier(Consumer c this.certificateVerifier = certificateVerifier; } + private void verifyX509Certificate(OAuth2ClientAuthenticationContext clientAuthenticationContext) { + OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication(); + if (SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) { + this.selfSignedCertificateVerifier.accept(clientAuthenticationContext); + } else { + verifyX509CertificateSubjectDN(clientAuthenticationContext); + } + } + private void verifyX509CertificateSubjectDN(OAuth2ClientAuthenticationContext clientAuthenticationContext) { OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication(); RegisteredClient registeredClient = clientAuthenticationContext.getRegisteredClient(); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509SelfSignedCertificateVerifier.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509SelfSignedCertificateVerifier.java new file mode 100644 index 000000000..78051149c --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509SelfSignedCertificateVerifier.java @@ -0,0 +1,180 @@ +/* + * Copyright 2020-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.net.URI; +import java.net.URISyntaxException; +import java.security.PublicKey; +import java.security.cert.X509Certificate; +import java.text.ParseException; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import javax.security.auth.x500.X500Principal; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKMatcher; +import com.nimbusds.jose.jwk.JWKSet; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +/** + * The default {@code X509Certificate} verifier for the {@code self_signed_tls_client_auth} authentication method. + * + * @author Joe Grandja + * @since 1.3 + * @see X509ClientCertificateAuthenticationProvider#setCertificateVerifier(Consumer) + */ +final class X509SelfSignedCertificateVerifier implements Consumer { + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1"; + private static final JWKMatcher HAS_X509_CERT_CHAIN_MATCHER = new JWKMatcher.Builder().hasX509CertChain(true).build(); + private final Function jwkSetSupplier = new JwkSetSupplier(); + + @Override + public void accept(OAuth2ClientAuthenticationContext clientAuthenticationContext) { + OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication(); + RegisteredClient registeredClient = clientAuthenticationContext.getRegisteredClient(); + X509Certificate[] clientCertificateChain = (X509Certificate[]) clientAuthentication.getCredentials(); + X509Certificate clientCertificate = clientCertificateChain[0]; + + X500Principal issuer = clientCertificate.getIssuerX500Principal(); + X500Principal subject = clientCertificate.getSubjectX500Principal(); + if (issuer == null || !issuer.equals(subject)) { + throwInvalidClient("x509_certificate_issuer"); + } + + JWKSet jwkSet = this.jwkSetSupplier.apply(registeredClient); + + boolean publicKeyMatches = false; + for (JWK jwk : jwkSet.filter(HAS_X509_CERT_CHAIN_MATCHER).getKeys()) { + X509Certificate x509Certificate = jwk.getParsedX509CertChain().get(0); + PublicKey publicKey = x509Certificate.getPublicKey(); + if (Arrays.equals(clientCertificate.getPublicKey().getEncoded(), publicKey.getEncoded())) { + publicKeyMatches = true; + break; + } + } + + if (!publicKeyMatches) { + throwInvalidClient("x509_certificate"); + } + } + + private static void throwInvalidClient(String parameterName) { + throwInvalidClient(parameterName, null); + } + + private static void throwInvalidClient(String parameterName, Throwable cause) { + OAuth2Error error = new OAuth2Error( + OAuth2ErrorCodes.INVALID_CLIENT, + "Client authentication failed: " + parameterName, + ERROR_URI + ); + throw new OAuth2AuthenticationException(error, error.toString(), cause); + } + + private static class JwkSetSupplier implements Function { + private static final MediaType APPLICATION_JWK_SET_JSON = new MediaType("application", "jwk-set+json"); + private final RestOperations restOperations; + private final Map> jwkSets = new ConcurrentHashMap<>(); + + private JwkSetSupplier() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(15_000); + requestFactory.setReadTimeout(15_000); + this.restOperations = new RestTemplate(requestFactory); + } + + @Override + public JWKSet apply(RegisteredClient registeredClient) { + Supplier jwkSetSupplier = this.jwkSets.computeIfAbsent( + registeredClient.getId(), (key) -> { + if (!StringUtils.hasText(registeredClient.getClientSettings().getJwkSetUrl())) { + throwInvalidClient("client_jwk_set_url"); + } + return new JwkSetHolder(registeredClient.getClientSettings().getJwkSetUrl()); + }); + return jwkSetSupplier.get(); + } + + private JWKSet retrieve(String jwkSetUrl) { + URI jwkSetUri = null; + try { + jwkSetUri = new URI(jwkSetUrl); + } catch (URISyntaxException ex) { + throwInvalidClient("jwk_set_uri", ex); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Arrays.asList(MediaType.APPLICATION_JSON, APPLICATION_JWK_SET_JSON)); + RequestEntity request = new RequestEntity<>(headers, HttpMethod.GET, jwkSetUri); + ResponseEntity response = null; + try { + response = this.restOperations.exchange(request, String.class); + } catch (Exception ex) { + throwInvalidClient("jwk_set_response_error", ex); + } + if (response.getStatusCode().value() != 200) { + throwInvalidClient("jwk_set_response_status"); + } + + JWKSet jwkSet = null; + try { + jwkSet = JWKSet.parse(response.getBody()); + } catch (ParseException ex) { + throwInvalidClient("jwk_set_response_body", ex); + } + + return jwkSet; + } + + private class JwkSetHolder implements Supplier { + private final String jwkSetUrl; + private JWKSet jwkSet; + + private JwkSetHolder(String jwkSetUrl) { + this.jwkSetUrl = jwkSetUrl; + } + + @Override + public JWKSet get() { + if (this.jwkSet == null) { + this.jwkSet = retrieve(this.jwkSetUrl); + } + return this.jwkSet; + } + + } + + } + +} 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 1b652237c..70639cee8 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 @@ private static Consumer> clientAuthenticationMethods() { authenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()); authenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue()); authenticationMethods.add("tls_client_auth"); + authenticationMethods.add("self_signed_tls_client_auth"); }; } 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 3e1db8b2c..ff987d954 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 @@ private static Consumer> clientAuthenticationMethods() { authenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()); authenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue()); authenticationMethods.add("tls_client_auth"); + authenticationMethods.add("self_signed_tls_client_auth"); }; } 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 index d5d617c72..aaacd25c8 100644 --- 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 @@ -35,7 +35,7 @@ /** * Attempts to extract a client {@code X509Certificate} chain from {@link HttpServletRequest} * and then converts to an {@link OAuth2ClientAuthenticationToken} used for authenticating the client - * using the {@code tls_client_auth} method. + * using the {@code tls_client_auth} or {@code self_signed_tls_client_auth} method. * * @author Joe Grandja * @since 1.3 @@ -46,13 +46,15 @@ public final class X509ClientCertificateAuthenticationConverter implements AuthenticationConverter { private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD = new ClientAuthenticationMethod("tls_client_auth"); + private static final ClientAuthenticationMethod SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD = + new ClientAuthenticationMethod("self_signed_tls_client_auth"); @Nullable @Override public Authentication convert(HttpServletRequest request) { X509Certificate[] clientCertificateChain = (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); - if (clientCertificateChain == null || clientCertificateChain.length <= 1) { + if (clientCertificateChain == null || clientCertificateChain.length == 0) { return null; } @@ -68,7 +70,12 @@ public Authentication convert(HttpServletRequest request) { Map additionalParameters = OAuth2EndpointUtils.getParametersIfMatchesAuthorizationCodeGrantRequest( request, OAuth2ParameterNames.CLIENT_ID); - return new OAuth2ClientAuthenticationToken(clientId, TLS_CLIENT_AUTH_AUTHENTICATION_METHOD, + ClientAuthenticationMethod clientAuthenticationMethod = + clientCertificateChain.length == 1 ? + SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD : + TLS_CLIENT_AUTH_AUTHENTICATION_METHOD; + + return new OAuth2ClientAuthenticationToken(clientId, clientAuthenticationMethod, clientCertificateChain, additionalParameters); } 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 index 778d9372b..148ec398f 100644 --- 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 @@ -15,12 +15,27 @@ */ package org.springframework.security.oauth2.server.authorization.authentication; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPublicKey; +import java.util.Collections; import java.util.HashMap; import java.util.Map; - +import java.util.UUID; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.Base64; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -59,18 +74,47 @@ public class X509ClientCertificateAuthenticationProviderTests { private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE); private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD = new ClientAuthenticationMethod("tls_client_auth"); + private static final ClientAuthenticationMethod SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD = + new ClientAuthenticationMethod("self_signed_tls_client_auth"); + private JWKSet selfSignedCertificateJwkSet; + private MockWebServer server; + private String clientJwkSetUrl; private RegisteredClientRepository registeredClientRepository; private OAuth2AuthorizationService authorizationService; private X509ClientCertificateAuthenticationProvider authenticationProvider; @BeforeEach - public void setUp() { + public void setUp() throws Exception { + // @formatter:off + X509Certificate selfSignedCertificate = TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE[0]; + RSAKey selfSignedRSAKey = new RSAKey.Builder((RSAPublicKey) selfSignedCertificate.getPublicKey()) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .x509CertChain(Collections.singletonList(Base64.encode(selfSignedCertificate.getEncoded()))) + .build(); + // @formatter:on + this.selfSignedCertificateJwkSet = new JWKSet(selfSignedRSAKey); + this.server = new MockWebServer(); + this.server.start(); + this.clientJwkSetUrl = this.server.url("/jwks").toString(); + // @formatter:off + MockResponse response = new MockResponse() + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setBody(this.selfSignedCertificateJwkSet.toString()); + // @formatter:on + this.server.enqueue(response); + this.registeredClientRepository = mock(RegisteredClientRepository.class); this.authorizationService = mock(OAuth2AuthorizationService.class); this.authenticationProvider = new X509ClientCertificateAuthenticationProvider( this.registeredClientRepository, this.authorizationService); } + @AfterEach + public void tearDown() throws Exception { + this.server.shutdown(); + } + @Test public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() { assertThatThrownBy(() -> new X509ClientCertificateAuthenticationProvider(null, this.authorizationService)) @@ -159,7 +203,7 @@ public void authenticateWhenX509CertificateNotProvidedThenThrowOAuth2Authenticat } @Test - public void authenticateWhenInvalidX509CertificateSubjectDNThenThrowOAuth2AuthenticationException() { + public void authenticateWhenPKIX509CertificateInvalidSubjectDNThenThrowOAuth2AuthenticationException() { // @formatter:off RegisteredClient registeredClient = TestRegisteredClients.registeredClient() .clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD) @@ -186,7 +230,7 @@ public void authenticateWhenInvalidX509CertificateSubjectDNThenThrowOAuth2Authen } @Test - public void authenticateWhenValidX509CertificateThenAuthenticated() { + public void authenticateWhenPKIX509CertificateValidThenAuthenticated() { // @formatter:off RegisteredClient registeredClient = TestRegisteredClients.registeredClient() .clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD) @@ -214,6 +258,191 @@ public void authenticateWhenValidX509CertificateThenAuthenticated() { assertThat(authenticationResult.getClientAuthenticationMethod()).isEqualTo(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD); } + @Test + public void authenticateWhenSelfSignedX509CertificateInvalidIssuerThenThrowOAuth2AuthenticationException() { + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD) + .clientSettings( + ClientSettings.builder() + .jwkSetUrl(this.clientJwkSetUrl) + .build() + ) + .build(); + // @formatter:on + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken( + registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD, + TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null); // PKI Certificate will have different issuer + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + assertThat(error.getDescription()).contains("x509_certificate_issuer"); + }); + } + + @Test + public void authenticateWhenSelfSignedX509CertificateMissingClientJwkSetUrlThenThrowOAuth2AuthenticationException() { + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD) + .build(); + // @formatter:on + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken( + registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD, + TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + assertThat(error.getDescription()).contains("client_jwk_set_url"); + }); + } + + @Test + public void authenticateWhenSelfSignedX509CertificateInvalidClientJwkSetUrlThenThrowOAuth2AuthenticationException() { + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD) + .clientSettings( + ClientSettings.builder() + .jwkSetUrl("https://this is an invalid URL") + .build() + ) + .build(); + // @formatter:on + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken( + registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD, + TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + assertThat(error.getDescription()).contains("jwk_set_uri"); + }); + } + + @Test + public void authenticateWhenSelfSignedX509CertificateJwkSetResponseErrorStatusThenThrowOAuth2AuthenticationException() { + MockResponse jwkSetResponse = new MockResponse().setResponseCode(400); + authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException( + jwkSetResponse, "jwk_set_response_error"); + } + + @Test + public void authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidStatusThenThrowOAuth2AuthenticationException() { + MockResponse jwkSetResponse = new MockResponse().setResponseCode(204); + authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException( + jwkSetResponse, "jwk_set_response_status"); + } + + @Test + public void authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidContentThenThrowOAuth2AuthenticationException() { + MockResponse jwkSetResponse = new MockResponse().setResponseCode(200).setBody("invalid-content"); + authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException( + jwkSetResponse, "jwk_set_response_body"); + } + + @Test + public void authenticateWhenSelfSignedX509CertificateJwkSetResponseNoMatchingKeysThenThrowOAuth2AuthenticationException() throws Exception { + // @formatter:off + X509Certificate pkiCertificate = TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0]; + RSAKey pkiRSAKey = new RSAKey.Builder((RSAPublicKey) pkiCertificate.getPublicKey()) + .keyUse(KeyUse.SIGNATURE) + .keyID(UUID.randomUUID().toString()) + .x509CertChain(Collections.singletonList(Base64.encode(pkiCertificate.getEncoded()))) + .build(); + // @formatter:on + + // @formatter:off + MockResponse jwkSetResponse = new MockResponse() + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setBody(new JWKSet(pkiRSAKey).toString()); + // @formatter:on + + authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException( + jwkSetResponse, "x509_certificate"); + } + + private void authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException( + final MockResponse jwkSetResponse, String expectedErrorDescription) { + + // @formatter:off + final Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return jwkSetResponse; + } + }; + this.server.setDispatcher(dispatcher); + // @formatter:on + + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD) + .clientSettings( + ClientSettings.builder() + .jwkSetUrl(this.clientJwkSetUrl) + .build() + ) + .build(); + // @formatter:on + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken( + registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD, + TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null); + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class) + .extracting(ex -> ((OAuth2AuthenticationException) ex).getError()) + .satisfies(error -> { + assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + assertThat(error.getDescription()).contains(expectedErrorDescription); + }); + } + + @Test + public void authenticateWhenSelfSignedX509CertificateValidThenAuthenticated() { + // @formatter:off + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .clientAuthenticationMethod(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD) + .clientSettings( + ClientSettings.builder() + .jwkSetUrl(this.clientJwkSetUrl) + .build() + ) + .build(); + // @formatter:on + when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId()))) + .thenReturn(registeredClient); + + OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken( + registeredClient.getClientId(), SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD, + TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null); + + OAuth2ClientAuthenticationToken authenticationResult = + (OAuth2ClientAuthenticationToken) this.authenticationProvider.authenticate(authentication); + + assertThat(authenticationResult.isAuthenticated()).isTrue(); + assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId()); + assertThat(authenticationResult.getCredentials()).isEqualTo(TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE); + assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(authenticationResult.getClientAuthenticationMethod()).isEqualTo(SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD); + } + @Test public void authenticateWhenPkceAndValidCodeVerifierThenAuthenticated() { // @formatter:off 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 fb4598883..db2a0eb00 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 @@ -264,7 +264,7 @@ public void requestWhenTokenRequestPostsClientCredentialsAndRequiresUpgradingThe } @Test - public void requestWhenTokenRequestWithX509ClientCertificateThenTokenResponse() throws Exception { + public void requestWhenTokenRequestWithPKIX509ClientCertificateThenTokenResponse() throws Exception { this.spring.register(AuthorizationServerConfiguration.class).autowire(); // @formatter:off 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 b6105294c..0e6b8881e 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 @@ -127,15 +127,15 @@ public void doFilterWhenConfigurationRequestThenConfigurationResponse() throws E assertThat(providerConfigurationResponse).contains("\"response_types_supported\":[\"code\"]"); assertThat(providerConfigurationResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\",\"urn:ietf:params:oauth:grant-type:device_code\",\"urn:ietf:params:oauth:grant-type:token-exchange\"]"); assertThat(providerConfigurationResponse).contains("\"revocation_endpoint\":\"https://example.com/oauth2/v1/revoke\""); - assertThat(providerConfigurationResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]"); + assertThat(providerConfigurationResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]"); assertThat(providerConfigurationResponse).contains("\"introspection_endpoint\":\"https://example.com/oauth2/v1/introspect\""); - assertThat(providerConfigurationResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]"); + assertThat(providerConfigurationResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]"); assertThat(providerConfigurationResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]"); assertThat(providerConfigurationResponse).contains("\"subject_types_supported\":[\"public\"]"); assertThat(providerConfigurationResponse).contains("\"id_token_signing_alg_values_supported\":[\"RS256\"]"); assertThat(providerConfigurationResponse).contains("\"userinfo_endpoint\":\"https://example.com/userinfo\""); assertThat(providerConfigurationResponse).contains("\"end_session_endpoint\":\"https://example.com/connect/logout\""); - assertThat(providerConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]"); + assertThat(providerConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]"); } @Test 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 74ea6df8b..8c2372c03 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 @@ -118,14 +118,14 @@ public void doFilterWhenAuthorizationServerMetadataRequestThenMetadataResponse() assertThat(authorizationServerMetadataResponse).contains("\"issuer\":\"https://example.com\""); assertThat(authorizationServerMetadataResponse).contains("\"authorization_endpoint\":\"https://example.com/oauth2/v1/authorize\""); assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint\":\"https://example.com/oauth2/v1/token\""); - assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]"); + assertThat(authorizationServerMetadataResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]"); assertThat(authorizationServerMetadataResponse).contains("\"jwks_uri\":\"https://example.com/oauth2/v1/jwks\""); assertThat(authorizationServerMetadataResponse).contains("\"response_types_supported\":[\"code\"]"); assertThat(authorizationServerMetadataResponse).contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\",\"urn:ietf:params:oauth:grant-type:device_code\",\"urn:ietf:params:oauth:grant-type:token-exchange\"]"); assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint\":\"https://example.com/oauth2/v1/revoke\""); - assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]"); + assertThat(authorizationServerMetadataResponse).contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]"); assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint\":\"https://example.com/oauth2/v1/introspect\""); - assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\"]"); + assertThat(authorizationServerMetadataResponse).contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]"); assertThat(authorizationServerMetadataResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]"); } 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 index 124576137..b25c8e687 100644 --- 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 @@ -15,6 +15,8 @@ */ package org.springframework.security.oauth2.server.authorization.web.authentication; +import java.security.cert.X509Certificate; + import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; @@ -46,10 +48,10 @@ public void convertWhenMissingX509CertificateThenReturnNull() { } @Test - public void convertWhenSelfSignedX509CertificateThenReturnNull() { + public void convertWhenEmptyX509CertificateThenReturnNull() { MockHttpServletRequest request = new MockHttpServletRequest(); request.setAttribute("jakarta.servlet.request.X509Certificate", - TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE); + new X509Certificate[0]); Authentication authentication = this.converter.convert(request); assertThat(authentication).isNull(); } @@ -102,4 +104,26 @@ public void convertWhenPkiX509CertificateThenReturnClientAuthenticationToken() { entry("custom-param-2", new String[] {"custom-value-1", "custom-value-2"})); } + @Test + public void convertWhenSelfSignedX509CertificateThenReturnClientAuthenticationToken() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute("jakarta.servlet.request.X509Certificate", + TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE); + request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-1"); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()); + request.addParameter(OAuth2ParameterNames.CODE, "code"); + request.addParameter("custom-param-1", "custom-value-1"); + request.addParameter("custom-param-2", "custom-value-1", "custom-value-2"); + OAuth2ClientAuthenticationToken authentication = (OAuth2ClientAuthenticationToken) this.converter.convert(request); + assertThat(authentication.getPrincipal()).isEqualTo("client-1"); + assertThat(authentication.getCredentials()).isEqualTo(TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE); + assertThat(authentication.getClientAuthenticationMethod().getValue()).isEqualTo("self_signed_tls_client_auth"); + assertThat(authentication.getAdditionalParameters()) + .containsOnly( + entry(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue()), + entry(OAuth2ParameterNames.CODE, "code"), + entry("custom-param-1", "custom-value-1"), + entry("custom-param-2", new String[] {"custom-value-1", "custom-value-2"})); + } + }