Skip to content

Commit 682c1f9

Browse files
committed
Add PKI Mutual-TLS client authentication method
Issue gh-101 Closes gh-1558
1 parent 7260966 commit 682c1f9

19 files changed

+1081
-15
lines changed

dependencies/spring-authorization-server-dependencies.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ dependencies {
1313
constraints {
1414
api "com.nimbusds:nimbus-jose-jwt:9.37.3"
1515
api "jakarta.servlet:jakarta.servlet-api:6.0.0"
16+
api "org.bouncycastle:bcpkix-jdk18on:1.77"
17+
api "org.bouncycastle:bcprov-jdk18on:1.77"
1618
api "org.junit.jupiter:junit-jupiter:5.10.1"
1719
api "org.assertj:assertj-core:3.25.1"
1820
api "org.mockito:mockito-core:4.11.0"

oauth2-authorization-server/spring-security-oauth2-authorization-server.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ dependencies {
2121

2222
testImplementation "org.springframework.security:spring-security-test"
2323
testImplementation "org.springframework:spring-webmvc"
24+
testImplementation "org.bouncycastle:bcpkix-jdk18on"
25+
testImplementation "org.bouncycastle:bcprov-jdk18on"
2426
testImplementation "org.junit.jupiter:junit-jupiter"
2527
testImplementation "org.assertj:assertj-core"
2628
testImplementation "org.mockito:mockito-core"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.security.oauth2.server.authorization.authentication;
17+
18+
import java.util.Collections;
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
import java.util.function.Consumer;
22+
23+
import org.springframework.lang.Nullable;
24+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
25+
import org.springframework.util.Assert;
26+
27+
/**
28+
* An {@link OAuth2AuthenticationContext} that holds an {@link OAuth2ClientAuthenticationToken} and additional information
29+
* and is used when validating an OAuth 2.0 Client Authentication.
30+
*
31+
* @author Joe Grandja
32+
* @since 1.3
33+
* @see OAuth2AuthenticationContext
34+
* @see OAuth2ClientAuthenticationToken
35+
* @see X509ClientCertificateAuthenticationProvider#setCertificateVerifier(Consumer)
36+
*/
37+
public final class OAuth2ClientAuthenticationContext implements OAuth2AuthenticationContext {
38+
private final Map<Object, Object> context;
39+
40+
private OAuth2ClientAuthenticationContext(Map<Object, Object> context) {
41+
this.context = Collections.unmodifiableMap(new HashMap<>(context));
42+
}
43+
44+
@SuppressWarnings("unchecked")
45+
@Nullable
46+
@Override
47+
public <V> V get(Object key) {
48+
return hasKey(key) ? (V) this.context.get(key) : null;
49+
}
50+
51+
@Override
52+
public boolean hasKey(Object key) {
53+
Assert.notNull(key, "key cannot be null");
54+
return this.context.containsKey(key);
55+
}
56+
57+
/**
58+
* Returns the {@link RegisteredClient registered client}.
59+
*
60+
* @return the {@link RegisteredClient}
61+
*/
62+
public RegisteredClient getRegisteredClient() {
63+
return get(RegisteredClient.class);
64+
}
65+
66+
/**
67+
* Constructs a new {@link Builder} with the provided {@link OAuth2ClientAuthenticationToken}.
68+
*
69+
* @param authentication the {@link OAuth2ClientAuthenticationToken}
70+
* @return the {@link Builder}
71+
*/
72+
public static Builder with(OAuth2ClientAuthenticationToken authentication) {
73+
return new Builder(authentication);
74+
}
75+
76+
/**
77+
* A builder for {@link OAuth2ClientAuthenticationContext}.
78+
*/
79+
public static final class Builder extends AbstractBuilder<OAuth2ClientAuthenticationContext, Builder> {
80+
81+
private Builder(OAuth2ClientAuthenticationToken authentication) {
82+
super(authentication);
83+
}
84+
85+
/**
86+
* Sets the {@link RegisteredClient registered client}.
87+
*
88+
* @param registeredClient the {@link RegisteredClient}
89+
* @return the {@link Builder} for further configuration
90+
*/
91+
public Builder registeredClient(RegisteredClient registeredClient) {
92+
return put(RegisteredClient.class, registeredClient);
93+
}
94+
95+
/**
96+
* Builds a new {@link OAuth2ClientAuthenticationContext}.
97+
*
98+
* @return the {@link OAuth2ClientAuthenticationContext}
99+
*/
100+
public OAuth2ClientAuthenticationContext build() {
101+
Assert.notNull(get(RegisteredClient.class), "registeredClient cannot be null");
102+
return new OAuth2ClientAuthenticationContext(getContext());
103+
}
104+
105+
}
106+
107+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.security.oauth2.server.authorization.authentication;
17+
18+
import java.security.cert.X509Certificate;
19+
import java.util.function.Consumer;
20+
21+
import org.apache.commons.logging.Log;
22+
import org.apache.commons.logging.LogFactory;
23+
24+
import org.springframework.security.authentication.AuthenticationProvider;
25+
import org.springframework.security.core.Authentication;
26+
import org.springframework.security.core.AuthenticationException;
27+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
28+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
29+
import org.springframework.security.oauth2.core.OAuth2Error;
30+
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
31+
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
32+
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
33+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
34+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
35+
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
36+
import org.springframework.util.Assert;
37+
import org.springframework.util.StringUtils;
38+
39+
/**
40+
* An {@link AuthenticationProvider} implementation used for OAuth 2.0 Client Authentication,
41+
* which authenticates the client {@code X509Certificate} received when the {@code tls_client_auth} authentication method is used.
42+
*
43+
* @author Joe Grandja
44+
* @since 1.3
45+
* @see AuthenticationProvider
46+
* @see OAuth2ClientAuthenticationToken
47+
* @see RegisteredClientRepository
48+
* @see OAuth2AuthorizationService
49+
*/
50+
public final class X509ClientCertificateAuthenticationProvider implements AuthenticationProvider {
51+
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1";
52+
private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
53+
new ClientAuthenticationMethod("tls_client_auth");
54+
private final Log logger = LogFactory.getLog(getClass());
55+
private final RegisteredClientRepository registeredClientRepository;
56+
private final CodeVerifierAuthenticator codeVerifierAuthenticator;
57+
private Consumer<OAuth2ClientAuthenticationContext> certificateVerifier = this::verifyX509CertificateSubjectDN;
58+
59+
/**
60+
* Constructs a {@code X509ClientCertificateAuthenticationProvider} using the provided parameters.
61+
*
62+
* @param registeredClientRepository the repository of registered clients
63+
* @param authorizationService the authorization service
64+
*/
65+
public X509ClientCertificateAuthenticationProvider(RegisteredClientRepository registeredClientRepository,
66+
OAuth2AuthorizationService authorizationService) {
67+
Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
68+
Assert.notNull(authorizationService, "authorizationService cannot be null");
69+
this.registeredClientRepository = registeredClientRepository;
70+
this.codeVerifierAuthenticator = new CodeVerifierAuthenticator(authorizationService);
71+
}
72+
73+
@Override
74+
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
75+
OAuth2ClientAuthenticationToken clientAuthentication =
76+
(OAuth2ClientAuthenticationToken) authentication;
77+
78+
if (!TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) {
79+
return null;
80+
}
81+
82+
String clientId = clientAuthentication.getPrincipal().toString();
83+
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
84+
if (registeredClient == null) {
85+
throwInvalidClient(OAuth2ParameterNames.CLIENT_ID);
86+
}
87+
88+
if (this.logger.isTraceEnabled()) {
89+
this.logger.trace("Retrieved registered client");
90+
}
91+
92+
if (!registeredClient.getClientAuthenticationMethods().contains(
93+
clientAuthentication.getClientAuthenticationMethod())) {
94+
throwInvalidClient("authentication_method");
95+
}
96+
97+
if (!(clientAuthentication.getCredentials() instanceof X509Certificate[])) {
98+
throwInvalidClient("credentials");
99+
}
100+
101+
OAuth2ClientAuthenticationContext authenticationContext =
102+
OAuth2ClientAuthenticationContext.with(clientAuthentication)
103+
.registeredClient(registeredClient)
104+
.build();
105+
this.certificateVerifier.accept(authenticationContext);
106+
107+
if (this.logger.isTraceEnabled()) {
108+
this.logger.trace("Validated client authentication parameters");
109+
}
110+
111+
// Validate the "code_verifier" parameter for the confidential client, if available
112+
this.codeVerifierAuthenticator.authenticateIfAvailable(clientAuthentication, registeredClient);
113+
114+
if (this.logger.isTraceEnabled()) {
115+
this.logger.trace("Authenticated client X509Certificate");
116+
}
117+
118+
return new OAuth2ClientAuthenticationToken(registeredClient,
119+
clientAuthentication.getClientAuthenticationMethod(), clientAuthentication.getCredentials());
120+
}
121+
122+
@Override
123+
public boolean supports(Class<?> authentication) {
124+
return OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication);
125+
}
126+
127+
/**
128+
* Sets the {@code Consumer} providing access to the {@link OAuth2ClientAuthenticationContext}
129+
* and is responsible for verifying the client {@code X509Certificate} associated in the {@link OAuth2ClientAuthenticationToken}.
130+
* The default implementation verifies the {@link ClientSettings#getX509CertificateSubjectDN() expected subject distinguished name}.
131+
*
132+
* <p>
133+
* <b>NOTE:</b> If verification fails, an {@link OAuth2AuthenticationException} MUST be thrown.
134+
*
135+
* @param certificateVerifier the {@code Consumer} providing access to the {@link OAuth2ClientAuthenticationContext} and is responsible for verifying the client {@code X509Certificate}
136+
*/
137+
public void setCertificateVerifier(Consumer<OAuth2ClientAuthenticationContext> certificateVerifier) {
138+
Assert.notNull(certificateVerifier, "certificateVerifier cannot be null");
139+
this.certificateVerifier = certificateVerifier;
140+
}
141+
142+
private void verifyX509CertificateSubjectDN(OAuth2ClientAuthenticationContext clientAuthenticationContext) {
143+
OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication();
144+
RegisteredClient registeredClient = clientAuthenticationContext.getRegisteredClient();
145+
X509Certificate[] clientCertificateChain = (X509Certificate[]) clientAuthentication.getCredentials();
146+
X509Certificate clientCertificate = clientCertificateChain[0];
147+
String expectedSubjectDN = registeredClient.getClientSettings().getX509CertificateSubjectDN();
148+
if (!StringUtils.hasText(expectedSubjectDN) ||
149+
!clientCertificate.getSubjectX500Principal().getName().equals(expectedSubjectDN)) {
150+
throwInvalidClient("x509_certificate_subject_dn");
151+
}
152+
}
153+
154+
private static void throwInvalidClient(String parameterName) {
155+
throwInvalidClient(parameterName, null);
156+
}
157+
158+
private static void throwInvalidClient(String parameterName, Throwable cause) {
159+
OAuth2Error error = new OAuth2Error(
160+
OAuth2ErrorCodes.INVALID_CLIENT,
161+
"Client authentication failed: " + parameterName,
162+
ERROR_URI
163+
);
164+
throw new OAuth2AuthenticationException(error, error.toString(), cause);
165+
}
166+
167+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.springframework.security.oauth2.server.authorization.authentication.JwtClientAssertionAuthenticationProvider;
3535
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
3636
import org.springframework.security.oauth2.server.authorization.authentication.PublicClientAuthenticationProvider;
37+
import org.springframework.security.oauth2.server.authorization.authentication.X509ClientCertificateAuthenticationProvider;
3738
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
3839
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
3940
import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter;
@@ -42,6 +43,7 @@
4243
import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter;
4344
import org.springframework.security.oauth2.server.authorization.web.authentication.JwtClientAssertionAuthenticationConverter;
4445
import org.springframework.security.oauth2.server.authorization.web.authentication.PublicClientAuthenticationConverter;
46+
import org.springframework.security.oauth2.server.authorization.web.authentication.X509ClientCertificateAuthenticationConverter;
4547
import org.springframework.security.web.authentication.AuthenticationConverter;
4648
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
4749
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
@@ -214,6 +216,7 @@ private static List<AuthenticationConverter> createDefaultAuthenticationConverte
214216
List<AuthenticationConverter> authenticationConverters = new ArrayList<>();
215217

216218
authenticationConverters.add(new JwtClientAssertionAuthenticationConverter());
219+
authenticationConverters.add(new X509ClientCertificateAuthenticationConverter());
217220
authenticationConverters.add(new ClientSecretBasicAuthenticationConverter());
218221
authenticationConverters.add(new ClientSecretPostAuthenticationConverter());
219222
authenticationConverters.add(new PublicClientAuthenticationConverter());
@@ -231,6 +234,10 @@ private static List<AuthenticationProvider> createDefaultAuthenticationProviders
231234
new JwtClientAssertionAuthenticationProvider(registeredClientRepository, authorizationService);
232235
authenticationProviders.add(jwtClientAssertionAuthenticationProvider);
233236

237+
X509ClientCertificateAuthenticationProvider x509ClientCertificateAuthenticationProvider =
238+
new X509ClientCertificateAuthenticationProvider(registeredClientRepository, authorizationService);
239+
authenticationProviders.add(x509ClientCertificateAuthenticationProvider);
240+
234241
ClientSecretAuthenticationProvider clientSecretAuthenticationProvider =
235242
new ClientSecretAuthenticationProvider(registeredClientRepository, authorizationService);
236243
PasswordEncoder passwordEncoder = OAuth2ConfigurerUtils.getOptionalBean(httpSecurity, PasswordEncoder.class);

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ private static Consumer<List<String>> clientAuthenticationMethods() {
129129
authenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue());
130130
authenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
131131
authenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue());
132+
authenticationMethods.add("tls_client_auth");
132133
};
133134
}
134135

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2022 the original author or authors.
2+
* Copyright 2020-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -78,6 +78,17 @@ public JwsAlgorithm getTokenEndpointAuthenticationSigningAlgorithm() {
7878
return getSetting(ConfigurationSettingNames.Client.TOKEN_ENDPOINT_AUTHENTICATION_SIGNING_ALGORITHM);
7979
}
8080

81+
/**
82+
* Returns the expected subject distinguished name associated to the client {@code X509Certificate}
83+
* received during client authentication when using the {@code tls_client_auth} method.
84+
*
85+
* @return the expected subject distinguished name associated to the client {@code X509Certificate} received during client authentication
86+
* @since 1.3
87+
*/
88+
public String getX509CertificateSubjectDN() {
89+
return getSetting(ConfigurationSettingNames.Client.X509_CERTIFICATE_SUBJECT_DN);
90+
}
91+
8192
/**
8293
* Constructs a new {@link Builder} with the default settings.
8394
*
@@ -156,6 +167,18 @@ public Builder tokenEndpointAuthenticationSigningAlgorithm(JwsAlgorithm authenti
156167
return setting(ConfigurationSettingNames.Client.TOKEN_ENDPOINT_AUTHENTICATION_SIGNING_ALGORITHM, authenticationSigningAlgorithm);
157168
}
158169

170+
/**
171+
* Sets the expected subject distinguished name associated to the client {@code X509Certificate}
172+
* received during client authentication when using the {@code tls_client_auth} method.
173+
*
174+
* @param x509CertificateSubjectDN the expected subject distinguished name associated to the client {@code X509Certificate} received during client authentication * @return the {@link Builder} for further configuration
175+
* @return the {@link Builder} for further configuration
176+
* @since 1.3
177+
*/
178+
public Builder x509CertificateSubjectDN(String x509CertificateSubjectDN) {
179+
return setting(ConfigurationSettingNames.Client.X509_CERTIFICATE_SUBJECT_DN, x509CertificateSubjectDN);
180+
}
181+
159182
/**
160183
* Builds the {@link ClientSettings}.
161184
*

0 commit comments

Comments
 (0)