Skip to content

Commit

Permalink
Add PKI Mutual-TLS client authentication method
Browse files Browse the repository at this point in the history
Issue gh-101

Closes gh-1558
  • Loading branch information
jgrandja committed Mar 16, 2024
1 parent 7260966 commit 682c1f9
Show file tree
Hide file tree
Showing 19 changed files with 1,081 additions and 15 deletions.
2 changes: 2 additions & 0 deletions dependencies/spring-authorization-server-dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.oauth2.server.authorization.authentication;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;

import org.springframework.lang.Nullable;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.util.Assert;

/**
* An {@link OAuth2AuthenticationContext} that holds an {@link OAuth2ClientAuthenticationToken} and additional information
* and is used when validating an OAuth 2.0 Client Authentication.
*
* @author Joe Grandja
* @since 1.3
* @see OAuth2AuthenticationContext
* @see OAuth2ClientAuthenticationToken
* @see X509ClientCertificateAuthenticationProvider#setCertificateVerifier(Consumer)
*/
public final class OAuth2ClientAuthenticationContext implements OAuth2AuthenticationContext {
private final Map<Object, Object> context;

private OAuth2ClientAuthenticationContext(Map<Object, Object> context) {
this.context = Collections.unmodifiableMap(new HashMap<>(context));
}

@SuppressWarnings("unchecked")
@Nullable
@Override
public <V> V get(Object key) {
return hasKey(key) ? (V) this.context.get(key) : null;
}

@Override
public boolean hasKey(Object key) {
Assert.notNull(key, "key cannot be null");
return this.context.containsKey(key);
}

/**
* Returns the {@link RegisteredClient registered client}.
*
* @return the {@link RegisteredClient}
*/
public RegisteredClient getRegisteredClient() {
return get(RegisteredClient.class);
}

/**
* Constructs a new {@link Builder} with the provided {@link OAuth2ClientAuthenticationToken}.
*
* @param authentication the {@link OAuth2ClientAuthenticationToken}
* @return the {@link Builder}
*/
public static Builder with(OAuth2ClientAuthenticationToken authentication) {
return new Builder(authentication);
}

/**
* A builder for {@link OAuth2ClientAuthenticationContext}.
*/
public static final class Builder extends AbstractBuilder<OAuth2ClientAuthenticationContext, Builder> {

private Builder(OAuth2ClientAuthenticationToken authentication) {
super(authentication);
}

/**
* Sets the {@link RegisteredClient registered client}.
*
* @param registeredClient the {@link RegisteredClient}
* @return the {@link Builder} for further configuration
*/
public Builder registeredClient(RegisteredClient registeredClient) {
return put(RegisteredClient.class, registeredClient);
}

/**
* Builds a new {@link OAuth2ClientAuthenticationContext}.
*
* @return the {@link OAuth2ClientAuthenticationContext}
*/
public OAuth2ClientAuthenticationContext build() {
Assert.notNull(get(RegisteredClient.class), "registeredClient cannot be null");
return new OAuth2ClientAuthenticationContext(getContext());
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Copyright 2020-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.oauth2.server.authorization.authentication;

import java.security.cert.X509Certificate;
import java.util.function.Consumer;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

/**
* An {@link AuthenticationProvider} implementation used for OAuth 2.0 Client Authentication,
* which authenticates the client {@code X509Certificate} received when the {@code tls_client_auth} authentication method is used.
*
* @author Joe Grandja
* @since 1.3
* @see AuthenticationProvider
* @see OAuth2ClientAuthenticationToken
* @see RegisteredClientRepository
* @see OAuth2AuthorizationService
*/
public final class X509ClientCertificateAuthenticationProvider implements AuthenticationProvider {
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1";
private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
new ClientAuthenticationMethod("tls_client_auth");
private final Log logger = LogFactory.getLog(getClass());
private final RegisteredClientRepository registeredClientRepository;
private final CodeVerifierAuthenticator codeVerifierAuthenticator;
private Consumer<OAuth2ClientAuthenticationContext> certificateVerifier = this::verifyX509CertificateSubjectDN;

/**
* Constructs a {@code X509ClientCertificateAuthenticationProvider} using the provided parameters.
*
* @param registeredClientRepository the repository of registered clients
* @param authorizationService the authorization service
*/
public X509ClientCertificateAuthenticationProvider(RegisteredClientRepository registeredClientRepository,
OAuth2AuthorizationService authorizationService) {
Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
Assert.notNull(authorizationService, "authorizationService cannot be null");
this.registeredClientRepository = registeredClientRepository;
this.codeVerifierAuthenticator = new CodeVerifierAuthenticator(authorizationService);
}

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2ClientAuthenticationToken clientAuthentication =
(OAuth2ClientAuthenticationToken) authentication;

if (!TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) {
return null;
}

String clientId = clientAuthentication.getPrincipal().toString();
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
if (registeredClient == null) {
throwInvalidClient(OAuth2ParameterNames.CLIENT_ID);
}

if (this.logger.isTraceEnabled()) {
this.logger.trace("Retrieved registered client");
}

if (!registeredClient.getClientAuthenticationMethods().contains(
clientAuthentication.getClientAuthenticationMethod())) {
throwInvalidClient("authentication_method");
}

if (!(clientAuthentication.getCredentials() instanceof X509Certificate[])) {
throwInvalidClient("credentials");
}

OAuth2ClientAuthenticationContext authenticationContext =
OAuth2ClientAuthenticationContext.with(clientAuthentication)
.registeredClient(registeredClient)
.build();
this.certificateVerifier.accept(authenticationContext);

if (this.logger.isTraceEnabled()) {
this.logger.trace("Validated client authentication parameters");
}

// Validate the "code_verifier" parameter for the confidential client, if available
this.codeVerifierAuthenticator.authenticateIfAvailable(clientAuthentication, registeredClient);

if (this.logger.isTraceEnabled()) {
this.logger.trace("Authenticated client X509Certificate");
}

return new OAuth2ClientAuthenticationToken(registeredClient,
clientAuthentication.getClientAuthenticationMethod(), clientAuthentication.getCredentials());
}

@Override
public boolean supports(Class<?> authentication) {
return OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication);
}

/**
* Sets the {@code Consumer} providing access to the {@link OAuth2ClientAuthenticationContext}
* and is responsible for verifying the client {@code X509Certificate} associated in the {@link OAuth2ClientAuthenticationToken}.
* The default implementation verifies the {@link ClientSettings#getX509CertificateSubjectDN() expected subject distinguished name}.
*
* <p>
* <b>NOTE:</b> If verification fails, an {@link OAuth2AuthenticationException} MUST be thrown.
*
* @param certificateVerifier the {@code Consumer} providing access to the {@link OAuth2ClientAuthenticationContext} and is responsible for verifying the client {@code X509Certificate}
*/
public void setCertificateVerifier(Consumer<OAuth2ClientAuthenticationContext> certificateVerifier) {
Assert.notNull(certificateVerifier, "certificateVerifier cannot be null");
this.certificateVerifier = certificateVerifier;
}

private void verifyX509CertificateSubjectDN(OAuth2ClientAuthenticationContext clientAuthenticationContext) {
OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication();
RegisteredClient registeredClient = clientAuthenticationContext.getRegisteredClient();
X509Certificate[] clientCertificateChain = (X509Certificate[]) clientAuthentication.getCredentials();
X509Certificate clientCertificate = clientCertificateChain[0];
String expectedSubjectDN = registeredClient.getClientSettings().getX509CertificateSubjectDN();
if (!StringUtils.hasText(expectedSubjectDN) ||
!clientCertificate.getSubjectX500Principal().getName().equals(expectedSubjectDN)) {
throwInvalidClient("x509_certificate_subject_dn");
}
}

private static void throwInvalidClient(String parameterName) {
throwInvalidClient(parameterName, null);
}

private static void throwInvalidClient(String parameterName, Throwable cause) {
OAuth2Error error = new OAuth2Error(
OAuth2ErrorCodes.INVALID_CLIENT,
"Client authentication failed: " + parameterName,
ERROR_URI
);
throw new OAuth2AuthenticationException(error, error.toString(), cause);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
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;
Expand All @@ -42,6 +43,7 @@
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;
Expand Down Expand Up @@ -214,6 +216,7 @@ private static List<AuthenticationConverter> createDefaultAuthenticationConverte
List<AuthenticationConverter> authenticationConverters = new ArrayList<>();

authenticationConverters.add(new JwtClientAssertionAuthenticationConverter());
authenticationConverters.add(new X509ClientCertificateAuthenticationConverter());
authenticationConverters.add(new ClientSecretBasicAuthenticationConverter());
authenticationConverters.add(new ClientSecretPostAuthenticationConverter());
authenticationConverters.add(new PublicClientAuthenticationConverter());
Expand All @@ -231,6 +234,10 @@ private static List<AuthenticationProvider> createDefaultAuthenticationProviders
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ private static Consumer<List<String>> clientAuthenticationMethods() {
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");
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -78,6 +78,17 @@ public JwsAlgorithm getTokenEndpointAuthenticationSigningAlgorithm() {
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.
*
Expand Down Expand Up @@ -156,6 +167,18 @@ public Builder tokenEndpointAuthenticationSigningAlgorithm(JwsAlgorithm authenti
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}.
*
Expand Down
Loading

0 comments on commit 682c1f9

Please sign in to comment.