Skip to content

Commit 79fe240

Browse files
committed
Add self-signed certificate Mutual-TLS client authentication method
Issue gh-101 Closes gh-1559
1 parent a0b7f6f commit 79fe240

File tree

10 files changed

+478
-20
lines changed

10 files changed

+478
-20
lines changed

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProvider.java

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838

3939
/**
4040
* 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.
41+
* which authenticates the client {@code X509Certificate} received
42+
* when the {@code tls_client_auth} or {@code self_signed_tls_client_auth} authentication method is used.
4243
*
4344
* @author Joe Grandja
4445
* @since 1.3
@@ -51,10 +52,14 @@ public final class X509ClientCertificateAuthenticationProvider implements Authen
5152
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1";
5253
private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
5354
new ClientAuthenticationMethod("tls_client_auth");
55+
private static final ClientAuthenticationMethod SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
56+
new ClientAuthenticationMethod("self_signed_tls_client_auth");
5457
private final Log logger = LogFactory.getLog(getClass());
5558
private final RegisteredClientRepository registeredClientRepository;
5659
private final CodeVerifierAuthenticator codeVerifierAuthenticator;
57-
private Consumer<OAuth2ClientAuthenticationContext> certificateVerifier = this::verifyX509CertificateSubjectDN;
60+
private final Consumer<OAuth2ClientAuthenticationContext> selfSignedCertificateVerifier =
61+
new X509SelfSignedCertificateVerifier();
62+
private Consumer<OAuth2ClientAuthenticationContext> certificateVerifier = this::verifyX509Certificate;
5863

5964
/**
6065
* Constructs a {@code X509ClientCertificateAuthenticationProvider} using the provided parameters.
@@ -75,7 +80,8 @@ public Authentication authenticate(Authentication authentication) throws Authent
7580
OAuth2ClientAuthenticationToken clientAuthentication =
7681
(OAuth2ClientAuthenticationToken) authentication;
7782

78-
if (!TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) {
83+
if (!TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod()) &&
84+
!SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) {
7985
return null;
8086
}
8187

@@ -127,7 +133,8 @@ public boolean supports(Class<?> authentication) {
127133
/**
128134
* Sets the {@code Consumer} providing access to the {@link OAuth2ClientAuthenticationContext}
129135
* 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}.
136+
* The default implementation for the {@code tls_client_auth} authentication method
137+
* verifies the {@link ClientSettings#getX509CertificateSubjectDN() expected subject distinguished name}.
131138
*
132139
* <p>
133140
* <b>NOTE:</b> If verification fails, an {@link OAuth2AuthenticationException} MUST be thrown.
@@ -139,6 +146,15 @@ public void setCertificateVerifier(Consumer<OAuth2ClientAuthenticationContext> c
139146
this.certificateVerifier = certificateVerifier;
140147
}
141148

149+
private void verifyX509Certificate(OAuth2ClientAuthenticationContext clientAuthenticationContext) {
150+
OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication();
151+
if (SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod())) {
152+
this.selfSignedCertificateVerifier.accept(clientAuthenticationContext);
153+
} else {
154+
verifyX509CertificateSubjectDN(clientAuthenticationContext);
155+
}
156+
}
157+
142158
private void verifyX509CertificateSubjectDN(OAuth2ClientAuthenticationContext clientAuthenticationContext) {
143159
OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication();
144160
RegisteredClient registeredClient = clientAuthenticationContext.getRegisteredClient();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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.net.URI;
19+
import java.net.URISyntaxException;
20+
import java.security.PublicKey;
21+
import java.security.cert.X509Certificate;
22+
import java.text.ParseException;
23+
import java.util.Arrays;
24+
import java.util.Map;
25+
import java.util.concurrent.ConcurrentHashMap;
26+
import java.util.function.Consumer;
27+
import java.util.function.Function;
28+
import java.util.function.Supplier;
29+
30+
import javax.security.auth.x500.X500Principal;
31+
32+
import com.nimbusds.jose.jwk.JWK;
33+
import com.nimbusds.jose.jwk.JWKMatcher;
34+
import com.nimbusds.jose.jwk.JWKSet;
35+
36+
import org.springframework.http.HttpHeaders;
37+
import org.springframework.http.HttpMethod;
38+
import org.springframework.http.MediaType;
39+
import org.springframework.http.RequestEntity;
40+
import org.springframework.http.ResponseEntity;
41+
import org.springframework.http.client.SimpleClientHttpRequestFactory;
42+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
43+
import org.springframework.security.oauth2.core.OAuth2Error;
44+
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
45+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
46+
import org.springframework.util.StringUtils;
47+
import org.springframework.web.client.RestOperations;
48+
import org.springframework.web.client.RestTemplate;
49+
50+
/**
51+
* The default {@code X509Certificate} verifier for the {@code self_signed_tls_client_auth} authentication method.
52+
*
53+
* @author Joe Grandja
54+
* @since 1.3
55+
* @see X509ClientCertificateAuthenticationProvider#setCertificateVerifier(Consumer)
56+
*/
57+
final class X509SelfSignedCertificateVerifier implements Consumer<OAuth2ClientAuthenticationContext> {
58+
private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1";
59+
private static final JWKMatcher HAS_X509_CERT_CHAIN_MATCHER = new JWKMatcher.Builder().hasX509CertChain(true).build();
60+
private final Function<RegisteredClient, JWKSet> jwkSetSupplier = new JwkSetSupplier();
61+
62+
@Override
63+
public void accept(OAuth2ClientAuthenticationContext clientAuthenticationContext) {
64+
OAuth2ClientAuthenticationToken clientAuthentication = clientAuthenticationContext.getAuthentication();
65+
RegisteredClient registeredClient = clientAuthenticationContext.getRegisteredClient();
66+
X509Certificate[] clientCertificateChain = (X509Certificate[]) clientAuthentication.getCredentials();
67+
X509Certificate clientCertificate = clientCertificateChain[0];
68+
69+
X500Principal issuer = clientCertificate.getIssuerX500Principal();
70+
X500Principal subject = clientCertificate.getSubjectX500Principal();
71+
if (issuer == null || !issuer.equals(subject)) {
72+
throwInvalidClient("x509_certificate_issuer");
73+
}
74+
75+
JWKSet jwkSet = this.jwkSetSupplier.apply(registeredClient);
76+
77+
boolean publicKeyMatches = false;
78+
for (JWK jwk : jwkSet.filter(HAS_X509_CERT_CHAIN_MATCHER).getKeys()) {
79+
X509Certificate x509Certificate = jwk.getParsedX509CertChain().get(0);
80+
PublicKey publicKey = x509Certificate.getPublicKey();
81+
if (Arrays.equals(clientCertificate.getPublicKey().getEncoded(), publicKey.getEncoded())) {
82+
publicKeyMatches = true;
83+
break;
84+
}
85+
}
86+
87+
if (!publicKeyMatches) {
88+
throwInvalidClient("x509_certificate");
89+
}
90+
}
91+
92+
private static void throwInvalidClient(String parameterName) {
93+
throwInvalidClient(parameterName, null);
94+
}
95+
96+
private static void throwInvalidClient(String parameterName, Throwable cause) {
97+
OAuth2Error error = new OAuth2Error(
98+
OAuth2ErrorCodes.INVALID_CLIENT,
99+
"Client authentication failed: " + parameterName,
100+
ERROR_URI
101+
);
102+
throw new OAuth2AuthenticationException(error, error.toString(), cause);
103+
}
104+
105+
private static class JwkSetSupplier implements Function<RegisteredClient, JWKSet> {
106+
private static final MediaType APPLICATION_JWK_SET_JSON = new MediaType("application", "jwk-set+json");
107+
private final RestOperations restOperations;
108+
private final Map<String, Supplier<JWKSet>> jwkSets = new ConcurrentHashMap<>();
109+
110+
private JwkSetSupplier() {
111+
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
112+
requestFactory.setConnectTimeout(15_000);
113+
requestFactory.setReadTimeout(15_000);
114+
this.restOperations = new RestTemplate(requestFactory);
115+
}
116+
117+
@Override
118+
public JWKSet apply(RegisteredClient registeredClient) {
119+
Supplier<JWKSet> jwkSetSupplier = this.jwkSets.computeIfAbsent(
120+
registeredClient.getId(), (key) -> {
121+
if (!StringUtils.hasText(registeredClient.getClientSettings().getJwkSetUrl())) {
122+
throwInvalidClient("client_jwk_set_url");
123+
}
124+
return new JwkSetHolder(registeredClient.getClientSettings().getJwkSetUrl());
125+
});
126+
return jwkSetSupplier.get();
127+
}
128+
129+
private JWKSet retrieve(String jwkSetUrl) {
130+
URI jwkSetUri = null;
131+
try {
132+
jwkSetUri = new URI(jwkSetUrl);
133+
} catch (URISyntaxException ex) {
134+
throwInvalidClient("jwk_set_uri", ex);
135+
}
136+
137+
HttpHeaders headers = new HttpHeaders();
138+
headers.setAccept(Arrays.asList(MediaType.APPLICATION_JSON, APPLICATION_JWK_SET_JSON));
139+
RequestEntity<Void> request = new RequestEntity<>(headers, HttpMethod.GET, jwkSetUri);
140+
ResponseEntity<String> response = null;
141+
try {
142+
response = this.restOperations.exchange(request, String.class);
143+
} catch (Exception ex) {
144+
throwInvalidClient("jwk_set_response_error", ex);
145+
}
146+
if (response.getStatusCode().value() != 200) {
147+
throwInvalidClient("jwk_set_response_status");
148+
}
149+
150+
JWKSet jwkSet = null;
151+
try {
152+
jwkSet = JWKSet.parse(response.getBody());
153+
} catch (ParseException ex) {
154+
throwInvalidClient("jwk_set_response_body", ex);
155+
}
156+
157+
return jwkSet;
158+
}
159+
160+
private class JwkSetHolder implements Supplier<JWKSet> {
161+
private final String jwkSetUrl;
162+
private JWKSet jwkSet;
163+
164+
private JwkSetHolder(String jwkSetUrl) {
165+
this.jwkSetUrl = jwkSetUrl;
166+
}
167+
168+
@Override
169+
public JWKSet get() {
170+
if (this.jwkSet == null) {
171+
this.jwkSet = retrieve(this.jwkSetUrl);
172+
}
173+
return this.jwkSet;
174+
}
175+
176+
}
177+
178+
}
179+
180+
}

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_JWT.getValue());
130130
authenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue());
131131
authenticationMethods.add("tls_client_auth");
132+
authenticationMethods.add("self_signed_tls_client_auth");
132133
};
133134
}
134135

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ private static Consumer<List<String>> clientAuthenticationMethods() {
122122
authenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
123123
authenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue());
124124
authenticationMethods.add("tls_client_auth");
125+
authenticationMethods.add("self_signed_tls_client_auth");
125126
};
126127
}
127128

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/X509ClientCertificateAuthenticationConverter.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
/**
3636
* Attempts to extract a client {@code X509Certificate} chain from {@link HttpServletRequest}
3737
* and then converts to an {@link OAuth2ClientAuthenticationToken} used for authenticating the client
38-
* using the {@code tls_client_auth} method.
38+
* using the {@code tls_client_auth} or {@code self_signed_tls_client_auth} method.
3939
*
4040
* @author Joe Grandja
4141
* @since 1.3
@@ -46,13 +46,15 @@
4646
public final class X509ClientCertificateAuthenticationConverter implements AuthenticationConverter {
4747
private static final ClientAuthenticationMethod TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
4848
new ClientAuthenticationMethod("tls_client_auth");
49+
private static final ClientAuthenticationMethod SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD =
50+
new ClientAuthenticationMethod("self_signed_tls_client_auth");
4951

5052
@Nullable
5153
@Override
5254
public Authentication convert(HttpServletRequest request) {
5355
X509Certificate[] clientCertificateChain =
5456
(X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate");
55-
if (clientCertificateChain == null || clientCertificateChain.length <= 1) {
57+
if (clientCertificateChain == null || clientCertificateChain.length == 0) {
5658
return null;
5759
}
5860

@@ -68,7 +70,12 @@ public Authentication convert(HttpServletRequest request) {
6870
Map<String, Object> additionalParameters = OAuth2EndpointUtils.getParametersIfMatchesAuthorizationCodeGrantRequest(
6971
request, OAuth2ParameterNames.CLIENT_ID);
7072

71-
return new OAuth2ClientAuthenticationToken(clientId, TLS_CLIENT_AUTH_AUTHENTICATION_METHOD,
73+
ClientAuthenticationMethod clientAuthenticationMethod =
74+
clientCertificateChain.length == 1 ?
75+
SELF_SIGNED_TLS_CLIENT_AUTH_AUTHENTICATION_METHOD :
76+
TLS_CLIENT_AUTH_AUTHENTICATION_METHOD;
77+
78+
return new OAuth2ClientAuthenticationToken(clientId, clientAuthenticationMethod,
7279
clientCertificateChain, additionalParameters);
7380
}
7481

0 commit comments

Comments
 (0)