diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/JwtBearerOAuth2AuthorizedClientProvider.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/JwtBearerOAuth2AuthorizedClientProvider.java new file mode 100644 index 0000000000..694c6f023d --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/JwtBearerOAuth2AuthorizedClientProvider.java @@ -0,0 +1,129 @@ +/* + * Copyright 2002-2021 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.client; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; + +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.client.endpoint.DefaultJwtBearerTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2JwtBearerGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AbstractOAuth2Token; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.util.Assert; + +/** + * An implementation of an {@link OAuth2AuthorizedClientProvider} for the + * {@link OAuth2JwtBearerGrantRequest#JWT_BEARER_GRANT_TYPE jwt-bearer} grant. + * + * @author Joe Grandja + * @since 5.5 + * @see OAuth2AuthorizedClientProvider + * @see DefaultJwtBearerTokenResponseClient + */ +public final class JwtBearerOAuth2AuthorizedClientProvider implements OAuth2AuthorizedClientProvider { + + private OAuth2AccessTokenResponseClient accessTokenResponseClient = new DefaultJwtBearerTokenResponseClient(); + + private Duration clockSkew = Duration.ofSeconds(60); + + private Clock clock = Clock.systemUTC(); + + /** + * Attempt to authorize the {@link OAuth2AuthorizationContext#getClientRegistration() + * client} in the provided {@code context}. Returns {@code null} if authorization is + * not supported, e.g. the client's + * {@link ClientRegistration#getAuthorizationGrantType() authorization grant type} is + * not {@link OAuth2JwtBearerGrantRequest#JWT_BEARER_GRANT_TYPE jwt-bearer}. + * @param context the context that holds authorization-specific state for the client + * @return the {@link OAuth2AuthorizedClient} or {@code null} if authorization is not + * supported + */ + @Override + @Nullable + public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) { + Assert.notNull(context, "context cannot be null"); + + ClientRegistration clientRegistration = context.getClientRegistration(); + if (!OAuth2JwtBearerGrantRequest.JWT_BEARER_GRANT_TYPE.equals(clientRegistration.getAuthorizationGrantType())) { + return null; + } + + Jwt jwt = context.getAttribute(OAuth2AuthorizationContext.JWT_ATTRIBUTE_NAME); + if (jwt == null) { + return null; + } + + OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient(); + if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) { + // If client is already authorized but access token is NOT expired than no + // need for re-authorization + return null; + } + + OAuth2JwtBearerGrantRequest jwtBearerGrantRequest = new OAuth2JwtBearerGrantRequest(clientRegistration, jwt); + OAuth2AccessTokenResponse tokenResponse = this.accessTokenResponseClient + .getTokenResponse(jwtBearerGrantRequest); + + return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(), + tokenResponse.getAccessToken()); + } + + private boolean hasTokenExpired(AbstractOAuth2Token token) { + return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew)); + } + + /** + * Sets the client used when requesting an access token credential at the Token + * Endpoint for the {@code jwt-bearer} grant. + * @param accessTokenResponseClient the client used when requesting an access token + * credential at the Token Endpoint for the {@code jwt-bearer} grant + */ + public void setAccessTokenResponseClient( + OAuth2AccessTokenResponseClient accessTokenResponseClient) { + Assert.notNull(accessTokenResponseClient, "accessTokenResponseClient cannot be null"); + this.accessTokenResponseClient = accessTokenResponseClient; + } + + /** + * Sets the maximum acceptable clock skew, which is used when checking the + * {@link OAuth2AuthorizedClient#getAccessToken() access token} expiry. The default is + * 60 seconds. An access token is considered expired if it's before + * {@code Instant.now(this.clock) - clockSkew}. + * @param clockSkew the maximum acceptable clock skew + */ + public void setClockSkew(Duration clockSkew) { + Assert.notNull(clockSkew, "clockSkew cannot be null"); + Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0"); + this.clockSkew = clockSkew; + } + + /** + * Sets the {@link Clock} used in {@link Instant#now(Clock)} when checking the access + * token expiry. + * @param clock the clock + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizationContext.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizationContext.java index a74a319d69..794bbb1223 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizationContext.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizationContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 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. @@ -60,6 +60,12 @@ public final class OAuth2AuthorizationContext { */ public static final String PASSWORD_ATTRIBUTE_NAME = OAuth2AuthorizationContext.class.getName().concat(".PASSWORD"); + /** + * The name of the {@link #getAttribute(String) attribute} in the context associated + * to the value for the JWT Bearer token. + */ + public static final String JWT_ATTRIBUTE_NAME = OAuth2AuthorizationContext.class.getName().concat(".JWT"); + private ClientRegistration clientRegistration; private OAuth2AuthorizedClient authorizedClient; diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizedClientProviderBuilder.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizedClientProviderBuilder.java index fa109dd2aa..5aaef72bf7 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizedClientProviderBuilder.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizedClientProviderBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 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. @@ -27,6 +27,7 @@ import java.util.function.Consumer; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2JwtBearerGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; import org.springframework.util.Assert; @@ -156,6 +157,29 @@ public final class OAuth2AuthorizedClientProviderBuilder { return OAuth2AuthorizedClientProviderBuilder.this; } + /** + * Configures support for the {@code jwt_bearer} grant. + * @return the {@link OAuth2AuthorizedClientProviderBuilder} + */ + public OAuth2AuthorizedClientProviderBuilder jwtBearer() { + this.builders.computeIfAbsent(JwtBearerOAuth2AuthorizedClientProvider.class, + (k) -> new JwtBearerGrantBuilder()); + return OAuth2AuthorizedClientProviderBuilder.this; + } + + /** + * Configures support for the {@code jwt_bearer} grant. + * @param builderConsumer a {@code Consumer} of {@link JwtBearerGrantBuilder} used for + * further configuration + * @return the {@link OAuth2AuthorizedClientProviderBuilder} + */ + public OAuth2AuthorizedClientProviderBuilder jwtBearer(Consumer builderConsumer) { + JwtBearerGrantBuilder builder = (JwtBearerGrantBuilder) this.builders + .computeIfAbsent(JwtBearerOAuth2AuthorizedClientProvider.class, (k) -> new JwtBearerGrantBuilder()); + builderConsumer.accept(builder); + return OAuth2AuthorizedClientProviderBuilder.this; + } + /** * Builds an instance of {@link DelegatingOAuth2AuthorizedClientProvider} composed of * one or more {@link OAuth2AuthorizedClientProvider}(s). @@ -205,7 +229,7 @@ public final class OAuth2AuthorizedClientProviderBuilder { /** * Sets the maximum acceptable clock skew, which is used when checking the access * token expiry. An access token is considered expired if it's before - * {@code Instant.now(this.clock) - clockSkew}. + * {@code Instant.now(this.clock) + clockSkew}. * @param clockSkew the maximum acceptable clock skew * @return the {@link PasswordGrantBuilder} */ @@ -246,6 +270,77 @@ public final class OAuth2AuthorizedClientProviderBuilder { } + /** + * A builder for the {@code jwt_bearer} grant. + */ + public final class JwtBearerGrantBuilder implements Builder { + + private OAuth2AccessTokenResponseClient accessTokenResponseClient; + + private Duration clockSkew; + + private Clock clock; + + private JwtBearerGrantBuilder() { + } + + /** + * Sets the client used when requesting an access token credential at the Token + * Endpoint. + * @param accessTokenResponseClient the client used when requesting an access + * token credential at the Token Endpoint + * @return the {@link JwtBearerGrantBuilder} + */ + public JwtBearerGrantBuilder accessTokenResponseClient( + OAuth2AccessTokenResponseClient accessTokenResponseClient) { + this.accessTokenResponseClient = accessTokenResponseClient; + return this; + } + + /** + * Sets the maximum acceptable clock skew, which is used when checking the access + * token expiry. An access token is considered expired if it's before + * {@code Instant.now(this.clock) + clockSkew}. + * @param clockSkew the maximum acceptable clock skew + * @return the {@link JwtBearerGrantBuilder} + */ + public JwtBearerGrantBuilder clockSkew(Duration clockSkew) { + this.clockSkew = clockSkew; + return this; + } + + /** + * Sets the {@link Clock} used in {@link Instant#now(Clock)} when checking the + * access token expiry. + * @param clock the clock + * @return the {@link JwtBearerGrantBuilder} + */ + public JwtBearerGrantBuilder clock(Clock clock) { + this.clock = clock; + return this; + } + + /** + * Builds an instance of {@link JwtBearerOAuth2AuthorizedClientProvider}. + * @return the {@link JwtBearerOAuth2AuthorizedClientProvider} + */ + @Override + public OAuth2AuthorizedClientProvider build() { + JwtBearerOAuth2AuthorizedClientProvider authorizedClientProvider = new JwtBearerOAuth2AuthorizedClientProvider(); + if (this.accessTokenResponseClient != null) { + authorizedClientProvider.setAccessTokenResponseClient(this.accessTokenResponseClient); + } + if (this.clockSkew != null) { + authorizedClientProvider.setClockSkew(this.clockSkew); + } + if (this.clock != null) { + authorizedClientProvider.setClock(this.clock); + } + return authorizedClientProvider; + } + + } + /** * A builder for the {@code client_credentials} grant. */ diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultJwtBearerTokenResponseClient.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultJwtBearerTokenResponseClient.java new file mode 100644 index 0000000000..453d40b04b --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/DefaultJwtBearerTokenResponseClient.java @@ -0,0 +1,133 @@ +/* + * Copyright 2002-2021 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.client.endpoint; + +import java.util.Arrays; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +/** + * The default implementation of an {@link OAuth2AccessTokenResponseClient} for the + * {@link OAuth2JwtBearerGrantRequest#JWT_BEARER_GRANT_TYPE jwt-bearer} grant. This + * implementation uses a {@link RestOperations} when requesting an access token credential + * at the Authorization Server's Token Endpoint. + * + * @author Joe Grandja + * @since 5.5 + * @see OAuth2AccessTokenResponseClient + * @see OAuth2JwtBearerGrantRequest + * @see OAuth2AccessTokenResponse + * @see Section + * 2.1 JWTs as Authorization Grants + */ +public final class DefaultJwtBearerTokenResponseClient + implements OAuth2AccessTokenResponseClient { + + private static final String INVALID_TOKEN_RESPONSE_ERROR_CODE = "invalid_token_response"; + + private Converter> requestEntityConverter = new OAuth2JwtBearerGrantRequestEntityConverter(); + + private RestOperations restOperations; + + public DefaultJwtBearerTokenResponseClient() { + RestTemplate restTemplate = new RestTemplate( + Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())); + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + this.restOperations = restTemplate; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2JwtBearerGrantRequest jwtBearerGrantRequest) { + Assert.notNull(jwtBearerGrantRequest, "jwtBearerGrantRequest cannot be null"); + + RequestEntity request = this.requestEntityConverter.convert(jwtBearerGrantRequest); + + ResponseEntity response; + try { + response = this.restOperations.exchange(request, OAuth2AccessTokenResponse.class); + } + catch (RestClientException ex) { + OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE, + "An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: " + + ex.getMessage(), + null); + throw new OAuth2AuthorizationException(oauth2Error, ex); + } + + OAuth2AccessTokenResponse tokenResponse = response.getBody(); + + if (CollectionUtils.isEmpty(tokenResponse.getAccessToken().getScopes())) { + // As per spec, in Section 5.1 Successful Access Token Response + // https://tools.ietf.org/html/rfc6749#section-5.1 + // If AccessTokenResponse.scope is empty, then default to the scope + // originally requested by the client in the Token Request + tokenResponse = OAuth2AccessTokenResponse.withResponse(tokenResponse) + .scopes(jwtBearerGrantRequest.getClientRegistration().getScopes()).build(); + } + + return tokenResponse; + } + + /** + * Sets the {@link Converter} used for converting the + * {@link OAuth2JwtBearerGrantRequest} to a {@link RequestEntity} representation of + * the OAuth 2.0 Access Token Request. + * @param requestEntityConverter the {@link Converter} used for converting to a + * {@link RequestEntity} representation of the Access Token Request + */ + public void setRequestEntityConverter( + Converter> requestEntityConverter) { + Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null"); + this.requestEntityConverter = requestEntityConverter; + } + + /** + * Sets the {@link RestOperations} used when requesting the OAuth 2.0 Access Token + * Response. + * + *

+ * NOTE: At a minimum, the supplied {@code restOperations} must be configured + * with the following: + *

    + *
  1. {@link HttpMessageConverter}'s - {@link FormHttpMessageConverter} and + * {@link OAuth2AccessTokenResponseHttpMessageConverter}
  2. + *
  3. {@link ResponseErrorHandler} - {@link OAuth2ErrorResponseErrorHandler}
  4. + *
+ * @param restOperations the {@link RestOperations} used when requesting the Access + * Token Response + */ + public void setRestOperations(RestOperations restOperations) { + Assert.notNull(restOperations, "restOperations cannot be null"); + this.restOperations = restOperations; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequest.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequest.java new file mode 100644 index 0000000000..d5f39bbbb3 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2021 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.client.endpoint; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.util.Assert; + +/** + * A JWT Bearer Authorization Grant request that holds a trusted {@link #getJwt() JWT} + * credential, which was granted by the Resource Owner to the + * {@link #getClientRegistration() Client}. + * + * @author Joe Grandja + * @since 5.5 + * @see AbstractOAuth2AuthorizationGrantRequest + * @see ClientRegistration + * @see Jwt + * @see Section + * 2.1 JWTs as Authorization Grants + */ +public class OAuth2JwtBearerGrantRequest extends AbstractOAuth2AuthorizationGrantRequest { + + public static final AuthorizationGrantType JWT_BEARER_GRANT_TYPE = new AuthorizationGrantType( + "urn:ietf:params:oauth:grant-type:jwt-bearer"); + + private final ClientRegistration clientRegistration; + + private final Jwt jwt; + + /** + * Constructs an {@code OAuth2JwtBearerGrantRequest} using the provided parameters. + * @param clientRegistration the client registration + * @param jwt the JWT Bearer token + */ + public OAuth2JwtBearerGrantRequest(ClientRegistration clientRegistration, Jwt jwt) { + super(JWT_BEARER_GRANT_TYPE); + Assert.notNull(clientRegistration, "clientRegistration cannot be null"); + Assert.notNull(jwt, "jwt cannot be null"); + Assert.isTrue(AuthorizationGrantType.JWT_BEARER.equals(clientRegistration.getAuthorizationGrantType()), + "clientRegistration.authorizationGrantType must be AuthorizationGrantType.JWT_BEARER"); + this.clientRegistration = clientRegistration; + this.jwt = jwt; + } + + /** + * Returns the {@link ClientRegistration client registration}. + * @return the {@link ClientRegistration} + */ + public ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + + /** + * Returns the {@link Jwt JWT Bearer token}. + * @return the {@link Jwt} + */ + public Jwt getJwt() { + return this.jwt; + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequestEntityConverter.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequestEntityConverter.java new file mode 100644 index 0000000000..637016976e --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequestEntityConverter.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2021 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.client.endpoint; + +import java.net.URI; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * A {@link Converter} that converts the provided {@link OAuth2JwtBearerGrantRequest} to a + * {@link RequestEntity} representation of an OAuth 2.0 Access Token Request for the Jwt + * Bearer Grant. + * + * @author Joe Grandja + * @since 5.5 + * @see Converter + * @see OAuth2JwtBearerGrantRequest + * @see RequestEntity + * @see Section + * 2.1 JWTs as Authorization Grants + */ +public class OAuth2JwtBearerGrantRequestEntityConverter + implements Converter> { + + /** + * Returns the {@link RequestEntity} used for the Access Token Request. + * @param jwtBearerGrantRequest the Jwt Bearer grant request + * @return the {@link RequestEntity} used for the Access Token Request + */ + @Override + public RequestEntity convert(OAuth2JwtBearerGrantRequest jwtBearerGrantRequest) { + ClientRegistration clientRegistration = jwtBearerGrantRequest.getClientRegistration(); + + HttpHeaders headers = OAuth2AuthorizationGrantRequestEntityUtils.getTokenRequestHeaders(clientRegistration); + MultiValueMap formParameters = this.buildFormParameters(jwtBearerGrantRequest); + URI uri = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getTokenUri()).build() + .toUri(); + + return new RequestEntity<>(formParameters, headers, HttpMethod.POST, uri); + } + + /** + * Returns a {@link MultiValueMap} of the form parameters used for the Access Token + * Request body. + * @param jwtBearerGrantRequest the Jwt Bearer grant request + * @return a {@link MultiValueMap} of the form parameters used for the Access Token + * Request body + */ + private MultiValueMap buildFormParameters(OAuth2JwtBearerGrantRequest jwtBearerGrantRequest) { + ClientRegistration clientRegistration = jwtBearerGrantRequest.getClientRegistration(); + + MultiValueMap formParameters = new LinkedMultiValueMap<>(); + formParameters.add(OAuth2ParameterNames.GRANT_TYPE, jwtBearerGrantRequest.getGrantType().getValue()); + formParameters.add(OAuth2ParameterNames.ASSERTION, jwtBearerGrantRequest.getJwt().getTokenValue()); + if (!CollectionUtils.isEmpty(clientRegistration.getScopes())) { + formParameters.add(OAuth2ParameterNames.SCOPE, StringUtils + .collectionToDelimitedString(jwtBearerGrantRequest.getClientRegistration().getScopes(), " ")); + } + if (ClientAuthenticationMethod.POST.equals(clientRegistration.getClientAuthenticationMethod())) { + formParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId()); + formParameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientRegistration.getClientSecret()); + } + + return formParameters; + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/JwtBearerOAuth2AuthorizedClientProviderTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/JwtBearerOAuth2AuthorizedClientProviderTests.java new file mode 100644 index 0000000000..5fcc9a4186 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/JwtBearerOAuth2AuthorizedClientProviderTests.java @@ -0,0 +1,205 @@ +/* + * Copyright 2002-2021 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.client; + +import java.time.Duration; +import java.time.Instant; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2JwtBearerGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.TestOAuth2AccessTokenResponses; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.TestJwts; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JwtBearerOAuth2AuthorizedClientProvider}. + * + * @author Hassene Laaribi + */ +public class JwtBearerOAuth2AuthorizedClientProviderTests { + + private JwtBearerOAuth2AuthorizedClientProvider authorizedClientProvider; + + private OAuth2AccessTokenResponseClient accessTokenResponseClient; + + private ClientRegistration clientRegistration; + + private Authentication principal; + + private Jwt jwtBearerToken; + + @Before + public void setup() { + this.authorizedClientProvider = new JwtBearerOAuth2AuthorizedClientProvider(); + this.accessTokenResponseClient = mock(OAuth2AccessTokenResponseClient.class); + this.authorizedClientProvider.setAccessTokenResponseClient(this.accessTokenResponseClient); + this.clientRegistration = TestClientRegistrations.jwtBearer().build(); + this.jwtBearerToken = TestJwts.jwt().build(); + this.principal = new TestingAuthenticationToken("principal", this.jwtBearerToken); + } + + @Test + public void setAccessTokenResponseClientWhenClientIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.authorizedClientProvider.setAccessTokenResponseClient(null)) + .withMessage("accessTokenResponseClient cannot be null"); + } + + @Test + public void setClockSkewWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.authorizedClientProvider.setClockSkew(null)) + .withMessage("clockSkew cannot be null"); + // @formatter:on + } + + @Test + public void setClockSkewWhenNegativeSecondsThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.authorizedClientProvider.setClockSkew(Duration.ofSeconds(-1))) + .withMessage("clockSkew must be >= 0"); + // @formatter:on + } + + @Test + public void setClockWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.authorizedClientProvider.setClock(null)) + .withMessage("clock cannot be null"); + // @formatter:on + } + + @Test + public void authorizeWhenContextIsNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.authorizedClientProvider.authorize(null)) + .withMessage("context cannot be null"); + // @formatter:on + } + + @Test + public void authorizeWhenNotJwtBearerThenUnableToAuthorize() { + ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials().build(); + // @formatter:off + OAuth2AuthorizationContext authorizationContext = OAuth2AuthorizationContext + .withClientRegistration(clientRegistration) + .principal(this.principal) + .build(); + // @formatter:on + assertThat(this.authorizedClientProvider.authorize(authorizationContext)).isNull(); + } + + @Test + public void authorizeWhenJwtBearerAndNotAuthorizedAndEmptyJwtThenUnableToAuthorize() { + // @formatter:off + OAuth2AuthorizationContext authorizationContext = OAuth2AuthorizationContext + .withClientRegistration(this.clientRegistration) + .principal(this.principal) + .attribute(OAuth2AuthorizationContext.JWT_ATTRIBUTE_NAME, null) + .build(); + // @formatter:on + assertThat(this.authorizedClientProvider.authorize(authorizationContext)).isNull(); + } + + @Test + public void authorizeWhenJwtBearerAndNotAuthorizedThenAuthorize() { + OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build(); + given(this.accessTokenResponseClient.getTokenResponse(any())).willReturn(accessTokenResponse); + // @formatter:off + OAuth2AuthorizationContext authorizationContext = OAuth2AuthorizationContext + .withClientRegistration(this.clientRegistration) + .principal(this.principal) + .attribute(OAuth2AuthorizationContext.JWT_ATTRIBUTE_NAME, this.jwtBearerToken) + .build(); + // @formatter:on + OAuth2AuthorizedClient authorizedClient = this.authorizedClientProvider.authorize(authorizationContext); + assertThat(authorizedClient.getClientRegistration()).isSameAs(this.clientRegistration); + assertThat(authorizedClient.getPrincipalName()).isEqualTo(this.principal.getName()); + assertThat(authorizedClient.getAccessToken()).isEqualTo(accessTokenResponse.getAccessToken()); + } + + @Test + public void authorizeWhenJwtBearerAndAuthorizedAndTokenExpiredThenReauthorize() { + Instant issuedAt = Instant.now().minus(Duration.ofDays(1)); + Instant expiresAt = issuedAt.plus(Duration.ofMinutes(60)); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "access-token-expired", issuedAt, expiresAt); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration, + this.principal.getName(), accessToken); + OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build(); + given(this.accessTokenResponseClient.getTokenResponse(any())).willReturn(accessTokenResponse); + // @formatter:off + OAuth2AuthorizationContext authorizationContext = OAuth2AuthorizationContext + .withAuthorizedClient(authorizedClient) + .attribute(OAuth2AuthorizationContext.JWT_ATTRIBUTE_NAME, this.jwtBearerToken) + .principal(this.principal) + .build(); + // @formatter:on + authorizedClient = this.authorizedClientProvider.authorize(authorizationContext); + assertThat(authorizedClient.getClientRegistration()).isSameAs(this.clientRegistration); + assertThat(authorizedClient.getPrincipalName()).isEqualTo(this.principal.getName()); + assertThat(authorizedClient.getAccessToken()).isEqualTo(accessTokenResponse.getAccessToken()); + } + + @Test + public void authorizeWhenJwtBearerAndAuthorizedAndTokenNotExpiredButClockSkewForcesExpiryThenReauthorize() { + Instant now = Instant.now(); + Instant issuedAt = now.minus(Duration.ofMinutes(60)); + Instant expiresAt = now.plus(Duration.ofMinutes(1)); + OAuth2AccessToken expiresInOneMinAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "access-token-1234", issuedAt, expiresAt); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration, + this.principal.getName(), expiresInOneMinAccessToken); // without refresh + // token + // Shorten the lifespan of the access token by 90 seconds, which will ultimately + // force it to expire on the client + this.authorizedClientProvider.setClockSkew(Duration.ofSeconds(90)); + OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build(); + given(this.accessTokenResponseClient.getTokenResponse(any())).willReturn(accessTokenResponse); + // @formatter:off + OAuth2AuthorizationContext authorizationContext = OAuth2AuthorizationContext + .withAuthorizedClient(authorizedClient) + .attribute(OAuth2AuthorizationContext.JWT_ATTRIBUTE_NAME, this.jwtBearerToken) + .principal(this.principal) + .build(); + // @formatter:on + OAuth2AuthorizedClient reauthorizedClient = this.authorizedClientProvider.authorize(authorizationContext); + assertThat(reauthorizedClient.getClientRegistration()).isSameAs(this.clientRegistration); + assertThat(reauthorizedClient.getPrincipalName()).isEqualTo(this.principal.getName()); + assertThat(reauthorizedClient.getAccessToken()).isEqualTo(accessTokenResponse.getAccessToken()); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/OAuth2AuthorizedClientProviderBuilderTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/OAuth2AuthorizedClientProviderBuilderTests.java index 3275de9195..72889e98c3 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/OAuth2AuthorizedClientProviderBuilderTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/OAuth2AuthorizedClientProviderBuilderTests.java @@ -28,6 +28,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.TestingAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.endpoint.DefaultClientCredentialsTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.DefaultJwtBearerTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.DefaultPasswordTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -36,6 +37,7 @@ import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.TestOAuth2RefreshTokens; import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; import org.springframework.security.oauth2.core.endpoint.TestOAuth2AccessTokenResponses; +import org.springframework.security.oauth2.jwt.TestJwts; import org.springframework.web.client.RestOperations; import static org.assertj.core.api.Assertions.assertThat; @@ -63,6 +65,8 @@ public class OAuth2AuthorizedClientProviderBuilderTests { private DefaultPasswordTokenResponseClient passwordTokenResponseClient; + private DefaultJwtBearerTokenResponseClient jwtBearerTokenResponseClient; + private Authentication principal; @SuppressWarnings("unchecked") @@ -78,6 +82,8 @@ public class OAuth2AuthorizedClientProviderBuilderTests { this.clientCredentialsTokenResponseClient.setRestOperations(this.accessTokenClient); this.passwordTokenResponseClient = new DefaultPasswordTokenResponseClient(); this.passwordTokenResponseClient.setRestOperations(this.accessTokenClient); + this.jwtBearerTokenResponseClient = new DefaultJwtBearerTokenResponseClient(); + this.jwtBearerTokenResponseClient.setRestOperations(this.accessTokenClient); this.principal = new TestingAuthenticationToken("principal", "password"); } @@ -157,6 +163,23 @@ public class OAuth2AuthorizedClientProviderBuilderTests { verify(this.accessTokenClient).exchange(any(RequestEntity.class), eq(OAuth2AccessTokenResponse.class)); } + @Test + public void buildWhenJwtBearerProviderThenProviderAuthorizes() { + // @formatter:off + OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() + .jwtBearer((configurer) -> configurer.accessTokenResponseClient(this.jwtBearerTokenResponseClient)) + .build(); + OAuth2AuthorizationContext authorizationContext = OAuth2AuthorizationContext + .withClientRegistration(TestClientRegistrations.jwtBearer().build()) + .principal(this.principal) + .attribute(OAuth2AuthorizationContext.JWT_ATTRIBUTE_NAME, TestJwts.jwt().build()) + .build(); + // @formatter:on + OAuth2AuthorizedClient authorizedClient = authorizedClientProvider.authorize(authorizationContext); + assertThat(authorizedClient).isNotNull(); + verify(this.accessTokenClient).exchange(any(RequestEntity.class), eq(OAuth2AccessTokenResponse.class)); + } + @Test public void buildWhenAllProvidersThenProvidersAuthorize() { OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() @@ -166,6 +189,7 @@ public class OAuth2AuthorizedClientProviderBuilderTests { .clientCredentials( (configurer) -> configurer.accessTokenResponseClient(this.clientCredentialsTokenResponseClient)) .password((configurer) -> configurer.accessTokenResponseClient(this.passwordTokenResponseClient)) + .jwtBearer((configurer) -> configurer.accessTokenResponseClient(this.jwtBearerTokenResponseClient)) .build(); ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build(); // authorization_code diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultJwtBearerTokenResponseClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultJwtBearerTokenResponseClientTests.java new file mode 100644 index 0000000000..4b69fc4342 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/DefaultJwtBearerTokenResponseClientTests.java @@ -0,0 +1,196 @@ +/* + * Copyright 2002-2021 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.client.endpoint; + +import java.net.URLEncoder; +import java.time.Instant; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.TestJwts; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link DefaultJwtBearerTokenResponseClient}. + * + * @author Hassene Laaribi + */ +public class DefaultJwtBearerTokenResponseClientTests { + + private static final String UTF_8_CHARSET = ";charset=UTF-8"; + + private final DefaultJwtBearerTokenResponseClient tokenResponseClient = new DefaultJwtBearerTokenResponseClient(); + + private ClientRegistration.Builder clientRegistrationBuilder; + + private final Jwt jwtBearerToken = TestJwts.jwt().build(); + + private MockWebServer server; + + @Before + public void setup() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + String tokenUri = this.server.url("/oauth2/token").toString(); + this.clientRegistrationBuilder = TestClientRegistrations.clientRegistration() + .authorizationGrantType(AuthorizationGrantType.JWT_BEARER).scope("read", "write").tokenUri(tokenUri); + } + + @After + public void cleanup() throws Exception { + this.server.shutdown(); + } + + @Test + public void setRequestEntityConverterWhenConverterIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.setRequestEntityConverter(null)); + } + + @Test + public void setRestOperationsWhenRestOperationsIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.setRestOperations(null)); + } + + @Test + public void getTokenResponseWhenRequestIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.tokenResponseClient.getTokenResponse(null)); + } + + @Test + public void getTokenResponseWhenSuccessResponseThenReturnAccessTokenResponse() throws Exception { + // @formatter:off + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + Instant expiresAtBefore = Instant.now().plusSeconds(3600); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.build(); + OAuth2JwtBearerGrantRequest jwtBearerGrantRequest = new OAuth2JwtBearerGrantRequest(clientRegistration, + this.jwtBearerToken); + OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient + .getTokenResponse(jwtBearerGrantRequest); + Instant expiresAtAfter = Instant.now().plusSeconds(3600); + RecordedRequest recordedRequest = this.server.takeRequest(); + assertThat(recordedRequest.getMethod()).isEqualTo(HttpMethod.POST.toString()); + assertThat(recordedRequest.getHeader(HttpHeaders.ACCEPT)) + .isEqualTo(MediaType.APPLICATION_JSON_VALUE + UTF_8_CHARSET); + assertThat(recordedRequest.getHeader(HttpHeaders.CONTENT_TYPE)) + .isEqualTo(MediaType.APPLICATION_FORM_URLENCODED_VALUE + UTF_8_CHARSET); + String formParameters = recordedRequest.getBody().readUtf8(); + assertThat(formParameters) + .contains("grant_type=" + URLEncoder.encode(AuthorizationGrantType.JWT_BEARER.getValue(), "UTF-8")); + assertThat(formParameters).contains("scope=read+write"); + assertThat(accessTokenResponse.getAccessToken().getTokenValue()).isEqualTo("access-token-1234"); + assertThat(accessTokenResponse.getAccessToken().getTokenType()).isEqualTo(OAuth2AccessToken.TokenType.BEARER); + assertThat(accessTokenResponse.getAccessToken().getExpiresAt()).isBetween(expiresAtBefore, expiresAtAfter); + assertThat(accessTokenResponse.getAccessToken().getScopes()) + .containsExactly(clientRegistration.getScopes().toArray(new String[0])); + assertThat(accessTokenResponse.getRefreshToken()).isNull(); + } + + @Test + public void getTokenResponseWhenSuccessResponseAndNotBearerTokenTypeThenThrowOAuth2AuthorizationException() { + // @formatter:off + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"not-bearer\",\n" + + " \"expires_in\": \"3600\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.build(); + OAuth2JwtBearerGrantRequest jwtBearerGrantRequest = new OAuth2JwtBearerGrantRequest(clientRegistration, + this.jwtBearerToken); + assertThatExceptionOfType(OAuth2AuthorizationException.class) + .isThrownBy(() -> this.tokenResponseClient.getTokenResponse(jwtBearerGrantRequest)) + .withMessageContaining( + "[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response") + .withMessageContaining("tokenType cannot be null"); + } + + @Test + public void getTokenResponseWhenSuccessResponseIncludesScopeThenAccessTokenHasResponseScope() throws Exception { + // @formatter:off + String accessTokenSuccessResponse = "{\n" + + " \"access_token\": \"access-token-1234\",\n" + + " \"token_type\": \"bearer\",\n" + + " \"expires_in\": \"3600\",\n" + + " \"scope\": \"read\"\n" + + "}\n"; + // @formatter:on + this.server.enqueue(jsonResponse(accessTokenSuccessResponse)); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.build(); + OAuth2JwtBearerGrantRequest jwtBearerGrantRequest = new OAuth2JwtBearerGrantRequest(clientRegistration, + this.jwtBearerToken); + OAuth2AccessTokenResponse accessTokenResponse = this.tokenResponseClient + .getTokenResponse(jwtBearerGrantRequest); + RecordedRequest recordedRequest = this.server.takeRequest(); + String formParameters = recordedRequest.getBody().readUtf8(); + assertThat(formParameters).contains("scope=read"); + assertThat(accessTokenResponse.getAccessToken().getScopes()).containsExactly("read"); + } + + @Test + public void getTokenResponseWhenErrorResponseThenThrowOAuth2AuthorizationException() { + String accessTokenErrorResponse = "{\n" + " \"error\": \"unauthorized_client\"\n" + "}\n"; + this.server.enqueue(jsonResponse(accessTokenErrorResponse).setResponseCode(400)); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.build(); + OAuth2JwtBearerGrantRequest jwtBearerGrantRequest = new OAuth2JwtBearerGrantRequest(clientRegistration, + this.jwtBearerToken); + assertThatExceptionOfType(OAuth2AuthorizationException.class) + .isThrownBy(() -> this.tokenResponseClient.getTokenResponse(jwtBearerGrantRequest)) + .withMessageContaining("[unauthorized_client]"); + } + + @Test + public void getTokenResponseWhenServerErrorResponseThenThrowOAuth2AuthorizationException() { + this.server.enqueue(new MockResponse().setResponseCode(500)); + ClientRegistration clientRegistration = this.clientRegistrationBuilder.build(); + OAuth2JwtBearerGrantRequest jwtBearerGrantRequest = new OAuth2JwtBearerGrantRequest(clientRegistration, + this.jwtBearerToken); + assertThatExceptionOfType(OAuth2AuthorizationException.class) + .isThrownBy(() -> this.tokenResponseClient.getTokenResponse(jwtBearerGrantRequest)) + .withMessageContaining("[invalid_token_response] An error occurred while attempting to " + + "retrieve the OAuth 2.0 Access Token Response"); + } + + private MockResponse jsonResponse(String json) { + return new MockResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody(json); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequestEntityConverterTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequestEntityConverterTests.java new file mode 100644 index 0000000000..a0f4006680 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequestEntityConverterTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2021 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.client.endpoint; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.TestJwts; +import org.springframework.util.MultiValueMap; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OAuth2JwtBearerGrantRequestEntityConverter}. + * + * @author Hassene Laaribi + */ +public class OAuth2JwtBearerGrantRequestEntityConverterTests { + + private static final String UTF_8_CHARSET = ";charset=UTF-8"; + + private final OAuth2JwtBearerGrantRequestEntityConverter converter = new OAuth2JwtBearerGrantRequestEntityConverter(); + + private OAuth2JwtBearerGrantRequest jwtBearerGrantRequest; + + private final Jwt jwtBearerToken = TestJwts.jwt().build(); + + @Before + public void setup() { + // @formatter:off + ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration() + .authorizationGrantType(AuthorizationGrantType.JWT_BEARER) + .scope("read", "write") + .build(); + // @formatter:on + this.jwtBearerGrantRequest = new OAuth2JwtBearerGrantRequest(clientRegistration, this.jwtBearerToken); + } + + @Test + public void convertWhenGrantRequestValidThenConverts() { + RequestEntity requestEntity = this.converter.convert(this.jwtBearerGrantRequest); + ClientRegistration clientRegistration = this.jwtBearerGrantRequest.getClientRegistration(); + assertThat(requestEntity.getMethod()).isEqualTo(HttpMethod.POST); + assertThat(requestEntity.getUrl().toASCIIString()) + .isEqualTo(clientRegistration.getProviderDetails().getTokenUri()); + HttpHeaders headers = requestEntity.getHeaders(); + assertThat(headers.getAccept()).contains(MediaType.valueOf(MediaType.APPLICATION_JSON_VALUE + UTF_8_CHARSET)); + assertThat(headers.getContentType()) + .isEqualTo(MediaType.valueOf(MediaType.APPLICATION_FORM_URLENCODED_VALUE + UTF_8_CHARSET)); + assertThat(headers.getFirst(HttpHeaders.AUTHORIZATION)).startsWith("Basic "); + MultiValueMap formParameters = (MultiValueMap) requestEntity.getBody(); + assertThat(formParameters.getFirst(OAuth2ParameterNames.GRANT_TYPE)) + .isEqualTo(AuthorizationGrantType.JWT_BEARER.getValue()); + assertThat(formParameters.getFirst(OAuth2ParameterNames.ASSERTION)) + .isEqualTo(this.jwtBearerToken.getTokenValue()); + assertThat(formParameters.getFirst(OAuth2ParameterNames.SCOPE)).isEqualTo("read write"); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequestTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequestTests.java new file mode 100644 index 0000000000..a02fba3c47 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/endpoint/OAuth2JwtBearerGrantRequestTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2021 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.client.endpoint; + +import org.junit.Test; + +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.TestJwts; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OAuth2JwtBearerGrantRequest}. + * + * @author Hassene Laaribi + */ +public class OAuth2JwtBearerGrantRequestTests { + + private final ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration() + .authorizationGrantType(AuthorizationGrantType.JWT_BEARER).build(); + + private final Jwt jwtBearerToken = TestJwts.jwt().build(); + + @Test + public void constructorWhenClientRegistrationIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2JwtBearerGrantRequest(null, this.jwtBearerToken)) + .withMessage("clientRegistration cannot be null"); + } + + @Test + public void constructorWhenJwtIsNullThenThrowIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2JwtBearerGrantRequest(this.clientRegistration, null)) + .withMessage("jwt cannot be null"); + } + + @Test + public void constructorWhenClientRegistrationInvalidGrantTypeThenThrowIllegalArgumentException() { + ClientRegistration registration = TestClientRegistrations.clientCredentials().build(); + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2JwtBearerGrantRequest(registration, this.jwtBearerToken)) + .withMessage("clientRegistration.authorizationGrantType must be AuthorizationGrantType.JWT_BEARER"); + } + + @Test + public void constructorWhenValidParametersProvidedThenCreated() { + OAuth2JwtBearerGrantRequest jwtBearerGrantRequest = new OAuth2JwtBearerGrantRequest(this.clientRegistration, + this.jwtBearerToken); + assertThat(jwtBearerGrantRequest.getGrantType()).isEqualTo(AuthorizationGrantType.JWT_BEARER); + assertThat(jwtBearerGrantRequest.getClientRegistration()).isSameAs(this.clientRegistration); + assertThat(jwtBearerGrantRequest.getJwt()).isEqualTo(this.jwtBearerToken); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/TestClientRegistrations.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/TestClientRegistrations.java index e2ef5a8bb1..931c863887 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/TestClientRegistrations.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/registration/TestClientRegistrations.java @@ -86,4 +86,17 @@ public final class TestClientRegistrations { // @formatter:on } + public static ClientRegistration.Builder jwtBearer() { + // @formatter:off + return ClientRegistration.withRegistrationId("jwt-bearer") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.JWT_BEARER) + .scope("read", "write") + .tokenUri("https://example.com/login/oauth/access_token") + .clientName("Client Name") + .clientId("client-id") + .clientSecret("client-secret"); + // @formatter:on + } + } diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunctionTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunctionTests.java index 2e3a793462..df70cf5aba 100644 --- a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunctionTests.java +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunctionTests.java @@ -77,6 +77,7 @@ import org.springframework.security.oauth2.client.endpoint.DefaultClientCredenti import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2JwtBearerGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2PasswordGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGrantRequest; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -139,6 +140,9 @@ public class ServletOAuth2AuthorizedClientExchangeFilterFunctionTests { @Mock private OAuth2AccessTokenResponseClient passwordTokenResponseClient; + @Mock + private OAuth2AccessTokenResponseClient jwtBearerTokenResponseClient; + @Mock private OAuth2AuthorizationFailureHandler authorizationFailureHandler; @@ -188,6 +192,7 @@ public class ServletOAuth2AuthorizedClientExchangeFilterFunctionTests { .clientCredentials( (configurer) -> configurer.accessTokenResponseClient(this.clientCredentialsTokenResponseClient)) .password((configurer) -> configurer.accessTokenResponseClient(this.passwordTokenResponseClient)) + .jwtBearer((configurer) -> configurer.accessTokenResponseClient(this.jwtBearerTokenResponseClient)) .build(); this.authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(this.clientRegistrationRepository, this.authorizedClientRepository); diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java index 8285358b0d..4b1154f2ab 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 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. @@ -59,6 +59,9 @@ public final class AuthorizationGrantType implements Serializable { public static final AuthorizationGrantType PASSWORD = new AuthorizationGrantType("password"); + public static final AuthorizationGrantType JWT_BEARER = new AuthorizationGrantType( + "urn:ietf:params:oauth:grant-type:jwt-bearer"); + private final String value; /** diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java index 464f6b1493..55b886f106 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java @@ -111,6 +111,11 @@ public interface OAuth2ParameterNames { */ String PASSWORD = "password"; + /** + * {@code assertion} - used in Access Token Request. + */ + String ASSERTION = "assertion"; + /** * {@code error} - used in Authorization Response and Access Token Response. */