Skip to content

Commit 116cdc5

Browse files
rlewczukjgrandja
authored andcommitted
Client authentication with JWT assertion
Closes spring-projectsgh-59
1 parent d95ef13 commit 116cdc5

File tree

32 files changed

+1798
-51
lines changed

32 files changed

+1798
-51
lines changed

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

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies {
1919
testCompile 'org.assertj:assertj-core'
2020
testCompile 'org.mockito:mockito-core'
2121
testCompile 'com.jayway.jsonpath:json-path'
22+
testCompile 'com.squareup.okhttp3:mockwebserver'
2223

2324
testRuntime 'org.hsqldb:hsqldb'
2425

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimAccessor.java

+24
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,20 @@ default String getTokenEndpointAuthenticationMethod() {
9999
return getClaimAsString(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD);
100100
}
101101

102+
/**
103+
* Returns the {@link SignatureAlgorithm JWS} algorithm that must be used for signing the JWT used to authenticate
104+
* the Client at the Token Endpoint for the {@code private_key_jwt} and {@code client_secret_jwt} authentication
105+
* methods {@code (token_endpoint_auth_signing_alg)}
106+
*
107+
* @return the {@link SignatureAlgorithm JWS} algorithm that must be used for signing the JWT used to authenticate
108+
* the Client at the Token Endpoint for the {@code private_key_jwt} and {@code client_secret_jwt}
109+
* authentication methods {@code (token_endpoint_auth_signing_alg)}
110+
* @since 0.2.1
111+
*/
112+
default String getTokenEndpointAuthenticationSigningAlgorithm() {
113+
return getClaimAsString(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_SIGNING_ALG);
114+
}
115+
102116
/**
103117
* Returns the OAuth 2.0 {@code grant_type} values that the Client will restrict itself to using {@code (grant_types)}.
104118
*
@@ -155,4 +169,14 @@ default URL getRegistrationClientUrl() {
155169
return getClaimAsURL(OidcClientMetadataClaimNames.REGISTRATION_CLIENT_URI);
156170
}
157171

172+
/**
173+
* Returns {@code URL} for the Client's JSON Web Key Set {@code (jwks_uri)}
174+
*
175+
* @return {@code URL} for the Client's JSON Web Key Set {@code (jwks_uri)}
176+
* @since 0.2.1
177+
*/
178+
default URL getJwkSetUrl() {
179+
return getClaimAsURL(OidcClientMetadataClaimNames.JWKS_URI);
180+
}
181+
158182
}

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientMetadataClaimNames.java

+14
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package org.springframework.security.oauth2.core.oidc;
1717

1818
import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
19+
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
1920

2021
/**
2122
* The names of the "claims" defined by OpenID Connect Dynamic Client Registration 1.0
@@ -95,4 +96,17 @@ public interface OidcClientMetadataClaimNames {
9596
*/
9697
String REGISTRATION_CLIENT_URI = "registration_client_uri";
9798

99+
/**
100+
* {@code jwks_uri} - {@code URL} for the Client's JSON Web Key Set
101+
* @since 0.2.1
102+
*/
103+
String JWKS_URI = "jwks_uri";
104+
105+
/**
106+
* {@code token_endpoint_auth_signing_alg} - {@link SignatureAlgorithm JWS} algorithm that must be used for signing
107+
* the JWT used to authenticate the Client at the Token Endpoint for the {@code private_key_jwt} and {@code client_secret_jwt}
108+
* authentication methods
109+
* @since 0.2.1
110+
*/
111+
String TOKEN_ENDPOINT_AUTH_SIGNING_ALG = "token_endpoint_auth_signing_alg";
98112
}

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/OidcClientRegistration.java

+24
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,20 @@ public Builder tokenEndpointAuthenticationMethod(String tokenEndpointAuthenticat
172172
return claim(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, tokenEndpointAuthenticationMethod);
173173
}
174174

175+
/**
176+
* Sets the {@link SignatureAlgorithm JWS} algorithm that must be used for signing the JWT used to authenticate
177+
* the Client at the Token Endpoint for the {@code private_key_jwt} and {@code client_secret_jwt} authentication
178+
* methods
179+
* @param signingAlgorithm the {@link SignatureAlgorithm JWS} algorithm that must be used for signing
180+
* the JWT used to authenticate the Client at the Token Endpoint for the {@code private_key_jwt} and
181+
* {@code client_secret_jwt} authentication methods
182+
* @return the {@link Builder} for further configuration
183+
* @since 0.2.1
184+
*/
185+
public Builder tokenEndpointAuthenticationSigningAlgorithm(String signingAlgorithm) {
186+
return claim(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, signingAlgorithm);
187+
}
188+
175189
/**
176190
* Add the OAuth 2.0 {@code grant_type} that the Client will restrict itself to using, OPTIONAL.
177191
*
@@ -273,6 +287,16 @@ public Builder registrationClientUrl(String registrationClientUrl) {
273287
return claim(OidcClientMetadataClaimNames.REGISTRATION_CLIENT_URI, registrationClientUrl);
274288
}
275289

290+
/**
291+
* Sets {@code URL} for the Client's JSON Web Key Set {@code (jwks_uri)}
292+
* @param jwksSetUrl {@code URL} for the Client's JSON Web Key Set {@code (jwks_uri)}
293+
* @return the {@link Builder} for further configuration
294+
* @since 0.2.1
295+
*/
296+
public Builder jwkSetUrl(String jwksSetUrl) {
297+
return claim(OidcClientMetadataClaimNames.JWKS_URI, jwksSetUrl);
298+
}
299+
276300
/**
277301
* Sets the claim.
278302
*

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java

+2
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ private MapOidcClientRegistrationConverter() {
150150
claimConverters.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, collectionStringConverter);
151151
claimConverters.put(OidcClientMetadataClaimNames.SCOPE, MapOidcClientRegistrationConverter::convertScope);
152152
claimConverters.put(OidcClientMetadataClaimNames.ID_TOKEN_SIGNED_RESPONSE_ALG, stringConverter);
153+
claimConverters.put(OidcClientMetadataClaimNames.JWKS_URI, stringConverter);
154+
claimConverters.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, stringConverter);
153155
this.claimTypeConverter = new ClaimTypeConverter(claimConverters);
154156
}
155157

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

+192
Original file line numberDiff line numberDiff line change
@@ -19,34 +19,59 @@
1919
import java.security.MessageDigest;
2020
import java.security.NoSuchAlgorithmException;
2121
import java.util.Base64;
22+
import java.util.Collections;
23+
import java.util.HashMap;
24+
import java.util.List;
2225
import java.util.Map;
26+
import java.util.Objects;
27+
import java.util.Set;
28+
import java.util.concurrent.ConcurrentHashMap;
29+
import java.util.function.Function;
2330

31+
import org.springframework.beans.factory.annotation.Autowired;
2432
import org.springframework.security.authentication.AuthenticationProvider;
2533
import org.springframework.security.core.Authentication;
2634
import org.springframework.security.core.AuthenticationException;
2735
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
2836
import org.springframework.security.crypto.password.PasswordEncoder;
2937
import org.springframework.security.oauth2.core.AuthorizationGrantType;
38+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
39+
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
3040
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
3141
import org.springframework.security.oauth2.core.OAuth2Error;
3242
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
3343
import org.springframework.security.oauth2.core.OAuth2TokenType;
44+
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
3445
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
3546
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
3647
import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
48+
import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
49+
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
50+
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
51+
import org.springframework.security.oauth2.jwt.Jwt;
52+
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
53+
import org.springframework.security.oauth2.jwt.JwtDecoder;
54+
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
55+
import org.springframework.security.oauth2.jwt.JwtException;
56+
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
57+
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
3758
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
3859
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
3960
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
4061
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
62+
import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
4163
import org.springframework.util.Assert;
4264
import org.springframework.util.StringUtils;
4365

66+
import javax.crypto.spec.SecretKeySpec;
67+
4468
/**
4569
* An {@link AuthenticationProvider} implementation used for authenticating an OAuth 2.0 Client.
4670
*
4771
* @author Joe Grandja
4872
* @author Patryk Kostrzewa
4973
* @author Daniel Garnier-Moiroux
74+
* @author Rafal Lewczuk
5075
* @since 0.0.1
5176
* @see AuthenticationProvider
5277
* @see OAuth2ClientAuthenticationToken
@@ -56,9 +81,15 @@
5681
*/
5782
public final class OAuth2ClientAuthenticationProvider implements AuthenticationProvider {
5883
private static final String CLIENT_AUTHENTICATION_ERROR_URI = "https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-01#section-3.2.1";
84+
85+
private static final ClientAuthenticationMethod JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD =
86+
new ClientAuthenticationMethod("urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
87+
5988
private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
6089
private final RegisteredClientRepository registeredClientRepository;
6190
private final OAuth2AuthorizationService authorizationService;
91+
private JwtDecoderFactory<RegisteredClient> jwtDecoderFactory;
92+
private ProviderSettings providerSettings;
6293
private PasswordEncoder passwordEncoder;
6394

6495
/**
@@ -74,6 +105,7 @@ public OAuth2ClientAuthenticationProvider(RegisteredClientRepository registeredC
74105
this.registeredClientRepository = registeredClientRepository;
75106
this.authorizationService = authorizationService;
76107
this.passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
108+
this.jwtDecoderFactory = new RegisteredClientJwtAssertionDecoderFactory();
77109
}
78110

79111
/**
@@ -89,11 +121,25 @@ public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
89121
this.passwordEncoder = passwordEncoder;
90122
}
91123

124+
@Autowired
125+
protected void setProviderSettings(ProviderSettings providerSettings) {
126+
this.providerSettings = providerSettings;
127+
}
128+
92129
@Override
93130
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
94131
OAuth2ClientAuthenticationToken clientAuthentication =
95132
(OAuth2ClientAuthenticationToken) authentication;
96133

134+
return JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD.equals(clientAuthentication.getClientAuthenticationMethod()) ?
135+
authenticateClientAssertion(authentication) :
136+
authenticationClientCredentials(authentication);
137+
}
138+
139+
private Authentication authenticationClientCredentials(Authentication authentication) throws AuthenticationException {
140+
OAuth2ClientAuthenticationToken clientAuthentication =
141+
(OAuth2ClientAuthenticationToken) authentication;
142+
97143
String clientId = clientAuthentication.getPrincipal().toString();
98144
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
99145
if (registeredClient == null) {
@@ -125,6 +171,64 @@ public Authentication authenticate(Authentication authentication) throws Authent
125171
clientAuthentication.getClientAuthenticationMethod(), clientAuthentication.getCredentials());
126172
}
127173

174+
private Authentication authenticateClientAssertion(Authentication authentication) throws AuthenticationException {
175+
OAuth2ClientAuthenticationToken clientAuthentication =
176+
(OAuth2ClientAuthenticationToken) authentication;
177+
178+
String clientId = clientAuthentication.getPrincipal().toString();
179+
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
180+
if (registeredClient == null) {
181+
throwInvalidClient(OAuth2ParameterNames.CLIENT_ID);
182+
}
183+
184+
Set<ClientAuthenticationMethod> allowedAuthenticationMethods = registeredClient.getClientAuthenticationMethods();
185+
186+
if (!allowedAuthenticationMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_JWT) &&
187+
!allowedAuthenticationMethods.contains(ClientAuthenticationMethod.PRIVATE_KEY_JWT)) {
188+
throwInvalidClient("authentication_method");
189+
}
190+
191+
boolean credentialsAuthenticated = false;
192+
193+
try {
194+
JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(registeredClient);
195+
Jwt jwt = jwtDecoder.decode(clientAuthentication.getCredentials().toString());
196+
List<String> aud = jwt.getClaimAsStringList("aud");
197+
String issuer = getIssuerUri(clientAuthentication.getRequestUri());
198+
if (aud == null || !aud.contains(issuer)) {
199+
throwInvalidClient(OAuth2ParameterNames.CLIENT_ASSERTION);
200+
}
201+
credentialsAuthenticated = true;
202+
} catch (JwtException e) {
203+
throwInvalidClient(OAuth2ParameterNames.CLIENT_ASSERTION);
204+
}
205+
206+
boolean pkceAuthenticated = authenticatePkceIfAvailable(clientAuthentication, registeredClient);
207+
credentialsAuthenticated = credentialsAuthenticated || pkceAuthenticated;
208+
if (!credentialsAuthenticated) {
209+
throwInvalidClient("credentials");
210+
}
211+
212+
JwsAlgorithm tokenEndpointSigningAlgorithm = registeredClient.getClientSettings().getTokenEndpointSigningAlgorithm();
213+
ClientAuthenticationMethod clientAuthentiationMethod = tokenEndpointSigningAlgorithm instanceof MacAlgorithm ?
214+
ClientAuthenticationMethod.CLIENT_SECRET_JWT : ClientAuthenticationMethod.PRIVATE_KEY_JWT;
215+
216+
return new OAuth2ClientAuthenticationToken(registeredClient,
217+
clientAuthentiationMethod, clientAuthentication.getCredentials());
218+
}
219+
220+
private String getIssuerUri(String requestUri) throws AuthenticationException {
221+
if (requestUri.endsWith(providerSettings.getTokenEndpoint())) {
222+
return providerSettings.getIssuer() + providerSettings.getTokenEndpoint();
223+
} else if (requestUri.endsWith(providerSettings.getTokenIntrospectionEndpoint())) {
224+
return providerSettings.getIssuer() + providerSettings.getTokenIntrospectionEndpoint();
225+
} else if (requestUri.endsWith(providerSettings.getTokenRevocationEndpoint())) {
226+
return providerSettings.getIssuer() + providerSettings.getTokenRevocationEndpoint();
227+
}
228+
throwInvalidClient(OAuth2ParameterNames.CLIENT_ASSERTION);
229+
return null;
230+
}
231+
128232
@Override
129233
public boolean supports(Class<?> authentication) {
130234
return OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication);
@@ -201,4 +305,92 @@ private static void throwInvalidClient(String parameterName) {
201305
throw new OAuth2AuthenticationException(error);
202306
}
203307

308+
private static class CachedJwtDecoder {
309+
private final NimbusJwtDecoder jwtDecoder;
310+
private final RegisteredClient registeredClient;
311+
312+
CachedJwtDecoder(NimbusJwtDecoder jwtDecoder, RegisteredClient registeredClient) {
313+
this.jwtDecoder = jwtDecoder;
314+
this.registeredClient = registeredClient;
315+
}
316+
}
317+
318+
private static class RegisteredClientJwtAssertionDecoderFactory implements JwtDecoderFactory<RegisteredClient> {
319+
320+
private static final String CLIENT_ASSERTION_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc7523#section-3";
321+
322+
private static final Map<JwsAlgorithm, String> JCA_ALGORITHM_MAPPINGS;
323+
324+
static {
325+
Map<JwsAlgorithm, String> mappings = new HashMap<>();
326+
mappings.put(MacAlgorithm.HS256, "HmacSHA256");
327+
mappings.put(MacAlgorithm.HS384, "HmacSHA384");
328+
mappings.put(MacAlgorithm.HS512, "HmacSHA512");
329+
JCA_ALGORITHM_MAPPINGS = Collections.unmodifiableMap(mappings);
330+
}
331+
332+
private final Function<RegisteredClient, JwsAlgorithm> jwsAlgorithmResolver =
333+
rc -> rc.getClientSettings().getTokenEndpointSigningAlgorithm();
334+
335+
private final Map<String, CachedJwtDecoder> cachedDecoders = new ConcurrentHashMap<>();
336+
337+
@Override
338+
public JwtDecoder createDecoder(RegisteredClient registeredClient) {
339+
Assert.notNull(registeredClient, "registeredClient cannot be null");
340+
341+
CachedJwtDecoder cachedDecoder = this.cachedDecoders.get(registeredClient.getClientId());
342+
if (cachedDecoder != null && registeredClient.equals(cachedDecoder.registeredClient)) {
343+
return cachedDecoder.jwtDecoder;
344+
}
345+
346+
cachedDecoder = new CachedJwtDecoder(buildDecoder(registeredClient), registeredClient);
347+
cachedDecoder.jwtDecoder.setJwtValidator(createTokenValidator(registeredClient));
348+
this.cachedDecoders.put(registeredClient.getClientId(), cachedDecoder);
349+
return cachedDecoder.jwtDecoder;
350+
}
351+
352+
private NimbusJwtDecoder buildDecoder(RegisteredClient registeredClient) {
353+
JwsAlgorithm jwsAlgorithm = this.jwsAlgorithmResolver.apply(registeredClient);
354+
355+
if (jwsAlgorithm != null && SignatureAlgorithm.class.isAssignableFrom(jwsAlgorithm.getClass())) {
356+
String jwkSetUrl = registeredClient.getClientSettings().getJwkSetUrl();
357+
if (!StringUtils.hasText(jwkSetUrl)) {
358+
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_CLIENT,
359+
"misconfigured client", CLIENT_ASSERTION_ERROR_URI);
360+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
361+
}
362+
return NimbusJwtDecoder.withJwkSetUri(jwkSetUrl).jwsAlgorithm((SignatureAlgorithm) jwsAlgorithm).build();
363+
}
364+
365+
if (jwsAlgorithm != null && MacAlgorithm.class.isAssignableFrom(jwsAlgorithm.getClass())) {
366+
String clientSecret = registeredClient.getClientSecret();
367+
if (!StringUtils.hasText(clientSecret)) {
368+
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_CLIENT,
369+
"misconfigured client", CLIENT_ASSERTION_ERROR_URI);
370+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
371+
}
372+
SecretKeySpec secretKeySpec = new SecretKeySpec(clientSecret.getBytes(StandardCharsets.UTF_8),
373+
JCA_ALGORITHM_MAPPINGS.get(jwsAlgorithm));
374+
return NimbusJwtDecoder.withSecretKey(secretKeySpec).macAlgorithm((MacAlgorithm) jwsAlgorithm).build();
375+
}
376+
377+
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_CLIENT,
378+
"misconfigured client", CLIENT_ASSERTION_ERROR_URI);
379+
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
380+
}
381+
382+
private OAuth2TokenValidator<Jwt> createTokenValidator(RegisteredClient registeredClient) {
383+
String clientId = registeredClient.getClientId();
384+
return new DelegatingOAuth2TokenValidator<>(
385+
new JwtClaimValidator<String>("iss", clientId::equals), // RFC 7523 section 3 (iss)
386+
new JwtClaimValidator<String>("sub", clientId::equals), // RFC 7523 section 3 (sub)
387+
new JwtClaimValidator<>("exp", Objects::nonNull), // RFC 7523 section 3 (exp != null)
388+
new JwtTimestampValidator() // RFC 7523 section 3 (exp, nbf)
389+
);
390+
// The `aud` claim is not verified here
391+
392+
// TODO RFC 7523 section 3 #7: JWT may contain "jti" claim that provides unique identified for the token (OPTIONAL)
393+
}
394+
}
395+
204396
}

0 commit comments

Comments
 (0)