Add PKI Mutual-TLS client authentication method

Issue gh-101

Closes gh-1558
This commit is contained in:
Joe Grandja
2024-03-12 07:34:33 -04:00
parent 726096613d
commit 682c1f936e
19 changed files with 1081 additions and 15 deletions

View File

@@ -0,0 +1,275 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.oauth2.server.authorization.authentication;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.util.TestX509Certificates;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Tests for {@link X509ClientCertificateAuthenticationProvider}.
*
* @author Joe Grandja
*/
public class X509ClientCertificateAuthenticationProviderTests {
// See RFC 7636: Appendix B. Example for the S256 code_challenge_method
// https://tools.ietf.org/html/rfc7636#appendix-B
private static final String S256_CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
private static final String S256_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
private static final String AUTHORIZATION_CODE = "code";
private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
new ClientAuthenticationMethod("tls_client_auth");
private RegisteredClientRepository registeredClientRepository;
private OAuth2AuthorizationService authorizationService;
private X509ClientCertificateAuthenticationProvider authenticationProvider;
@BeforeEach
public void setUp() {
this.registeredClientRepository = mock(RegisteredClientRepository.class);
this.authorizationService = mock(OAuth2AuthorizationService.class);
this.authenticationProvider = new X509ClientCertificateAuthenticationProvider(
this.registeredClientRepository, this.authorizationService);
}
@Test
public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
assertThatThrownBy(() -> new X509ClientCertificateAuthenticationProvider(null, this.authorizationService))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("registeredClientRepository cannot be null");
}
@Test
public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
assertThatThrownBy(() -> new X509ClientCertificateAuthenticationProvider(this.registeredClientRepository, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("authorizationService cannot be null");
}
@Test
public void setCertificateVerifierWhenNullThenThrowIllegalArgumentException() {
assertThatThrownBy(() -> this.authenticationProvider.setCertificateVerifier(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("certificateVerifier cannot be null");
}
@Test
public void supportsWhenTypeOAuth2ClientAuthenticationTokenThenReturnTrue() {
assertThat(this.authenticationProvider.supports(OAuth2ClientAuthenticationToken.class)).isTrue();
}
@Test
public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationException() {
// @formatter:off
RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
.build();
// @formatter:on
when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
.thenReturn(registeredClient);
OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
registeredClient.getClientId() + "-invalid", TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);
assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
.isInstanceOf(OAuth2AuthenticationException.class)
.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
.satisfies(error -> {
assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID);
});
}
@Test
public void authenticateWhenUnsupportedClientAuthenticationMethodThenThrowOAuth2AuthenticationException() {
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
.thenReturn(registeredClient);
OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);
assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
.isInstanceOf(OAuth2AuthenticationException.class)
.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
.satisfies(error -> {
assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
assertThat(error.getDescription()).contains("authentication_method");
});
}
@Test
public void authenticateWhenX509CertificateNotProvidedThenThrowOAuth2AuthenticationException() {
// @formatter:off
RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
.build();
// @formatter:on
when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
.thenReturn(registeredClient);
OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD, null, null);
assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
.isInstanceOf(OAuth2AuthenticationException.class)
.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
.satisfies(error -> {
assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
assertThat(error.getDescription()).contains("credentials");
});
}
@Test
public void authenticateWhenInvalidX509CertificateSubjectDNThenThrowOAuth2AuthenticationException() {
// @formatter:off
RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
.clientSettings(
ClientSettings.builder()
.x509CertificateSubjectDN("CN=demo-client-sample-2,OU=Spring Samples,O=Spring,C=US")
.build()
)
.build();
// @formatter:on
when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
.thenReturn(registeredClient);
OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);
assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
.isInstanceOf(OAuth2AuthenticationException.class)
.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
.satisfies(error -> {
assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
assertThat(error.getDescription()).contains("x509_certificate_subject_dn");
});
}
@Test
public void authenticateWhenValidX509CertificateThenAuthenticated() {
// @formatter:off
RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
.clientSettings(
ClientSettings.builder()
.x509CertificateSubjectDN(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0].getSubjectX500Principal().getName())
.build()
)
.build();
// @formatter:on
when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
.thenReturn(registeredClient);
OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);
OAuth2ClientAuthenticationToken authenticationResult =
(OAuth2ClientAuthenticationToken) this.authenticationProvider.authenticate(authentication);
assertThat(authenticationResult.isAuthenticated()).isTrue();
assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
assertThat(authenticationResult.getCredentials()).isEqualTo(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE);
assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
assertThat(authenticationResult.getClientAuthenticationMethod()).isEqualTo(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD);
}
@Test
public void authenticateWhenPkceAndValidCodeVerifierThenAuthenticated() {
// @formatter:off
RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
.clientAuthenticationMethod(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD)
.clientSettings(
ClientSettings.builder()
.x509CertificateSubjectDN(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0].getSubjectX500Principal().getName())
.build()
)
.build();
// @formatter:on
when(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
.thenReturn(registeredClient);
OAuth2Authorization authorization = TestOAuth2Authorizations
.authorization(registeredClient, createPkceAuthorizationParametersS256())
.build();
when(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
.thenReturn(authorization);
Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
registeredClient.getClientId(), TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, parameters);
OAuth2ClientAuthenticationToken authenticationResult =
(OAuth2ClientAuthenticationToken) this.authenticationProvider.authenticate(authentication);
verify(this.authorizationService).findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE));
assertThat(authenticationResult.isAuthenticated()).isTrue();
assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
assertThat(authenticationResult.getCredentials()).isEqualTo(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE);
assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
assertThat(authenticationResult.getClientAuthenticationMethod()).isEqualTo(TLS_CLIENT_AUTH_AUTHENTICATION_METHOD);
}
private static Map<String, Object> createPkceAuthorizationParametersS256() {
Map<String, Object> parameters = new HashMap<>();
parameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
parameters.put(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE);
return parameters;
}
private static Map<String, Object> createPkceTokenParameters(String codeVerifier) {
Map<String, Object> parameters = createAuthorizationCodeTokenParameters();
parameters.put(PkceParameterNames.CODE_VERIFIER, codeVerifier);
return parameters;
}
private static Map<String, Object> createAuthorizationCodeTokenParameters() {
Map<String, Object> parameters = new HashMap<>();
parameters.put(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
parameters.put(OAuth2ParameterNames.CODE, AUTHORIZATION_CODE);
return parameters;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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