From c40aec2eb422eb92988214b5acc8dfaae2d451c6 Mon Sep 17 00:00:00 2001 From: Alexey Nesterov Date: Thu, 11 Jun 2020 14:54:01 +0100 Subject: [PATCH] Add client_credentials grant type support Closes gh-51 --- .../OAuth2AuthorizationServerConfigurer.java | 6 + core/spring-authorization-server-core.gradle | 1 + ...ientCredentialsAuthenticationProvider.java | 85 +++++++++++++ ...2ClientCredentialsAuthenticationToken.java | 68 ++++++++++ ...orizationGrantAuthenticationConverter.java | 61 +++++++++ .../web/OAuth2TokenEndpointFilter.java | 55 +++++++- ...redentialsAuthenticationProviderTests.java | 116 +++++++++++++++++ ...ntCredentialsAuthenticationTokenTests.java | 73 +++++++++++ ...tionGrantAuthenticationConverterTests.java | 96 ++++++++++++++ .../OAuth2ClientCredentialsGrantTests.java | 119 ++++++++++++++++++ .../web/OAuth2TokenEndpointFilterTests.java | 67 ++++++++++ gradle/dependency-management.gradle | 1 + 12 files changed, 743 insertions(+), 5 deletions(-) create mode 100644 core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProvider.java create mode 100644 core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationToken.java create mode 100644 core/src/main/java/org/springframework/security/oauth2/server/authorization/web/DelegatingAuthorizationGrantAuthenticationConverter.java create mode 100644 core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java create mode 100644 core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationTokenTests.java create mode 100644 core/src/test/java/org/springframework/security/oauth2/server/authorization/web/DelegatingAuthorizationGrantAuthenticationConverterTests.java create mode 100644 core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2ClientCredentialsGrantTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java index 77ccb29..ef3ac5f 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java @@ -26,6 +26,7 @@ import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2Au import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter; import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter; @@ -88,7 +89,12 @@ public final class OAuth2AuthorizationServerConfigurerSection 4.4 Client Credentials Grant + * @see Section 4.4.2 Access Token Request + */ + +public class OAuth2ClientCredentialsAuthenticationProvider implements AuthenticationProvider { + + private final StringKeyGenerator accessTokenGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder()); + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2ClientCredentialsAuthenticationToken clientCredentialsAuthenticationToken = + (OAuth2ClientCredentialsAuthenticationToken) authentication; + + OAuth2ClientAuthenticationToken clientPrincipal = null; + if (OAuth2ClientAuthenticationToken.class.isAssignableFrom(clientCredentialsAuthenticationToken.getPrincipal().getClass())) { + clientPrincipal = (OAuth2ClientAuthenticationToken) clientCredentialsAuthenticationToken.getPrincipal(); + } + + if (clientPrincipal == null || !clientPrincipal.isAuthenticated()) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_CLIENT)); + } + + Set clientScopes = clientPrincipal.getRegisteredClient().getScopes(); + Set requestedScopes = clientCredentialsAuthenticationToken.getScopes(); + if (!clientScopes.containsAll(requestedScopes)) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_SCOPE)); + } + + if (requestedScopes == null || requestedScopes.isEmpty()) { + requestedScopes = clientScopes; + } + + String tokenValue = this.accessTokenGenerator.generateKey(); + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS); // TODO Allow configuration for access token lifespan + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + tokenValue, issuedAt, expiresAt, requestedScopes); + + return new OAuth2AccessTokenAuthenticationToken( + clientPrincipal.getRegisteredClient(), clientPrincipal, accessToken); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2ClientCredentialsAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationToken.java b/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationToken.java new file mode 100644 index 0000000..575cb0d --- /dev/null +++ b/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationToken.java @@ -0,0 +1,68 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.Set; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.Version; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation used for the OAuth 2.0 Client Credentials Grant. + * + * @author Alexey Nesterov + * @since 0.0.1 + * @see Authentication + * @see OAuth2ClientCredentialsAuthenticationProvider + */ +public class OAuth2ClientCredentialsAuthenticationToken extends AbstractAuthenticationToken { + + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + + private final Authentication clientPrincipal; + private final Set scopes; + + public OAuth2ClientCredentialsAuthenticationToken(Authentication clientPrincipal, Set scopes) { + super(Collections.emptyList()); + Assert.notNull(clientPrincipal, "clientPrincipal cannot be null"); + Assert.notNull(scopes, "scopes cannot be null"); + this.clientPrincipal = clientPrincipal; + this.scopes = scopes; + } + + @SuppressWarnings("unchecked") + public OAuth2ClientCredentialsAuthenticationToken(OAuth2ClientAuthenticationToken clientPrincipal) { + this(clientPrincipal, Collections.EMPTY_SET); + } + + @Override + public Object getCredentials() { + return ""; + } + + @Override + public Object getPrincipal() { + return this.clientPrincipal; + } + + public Set getScopes() { + return this.scopes; + } +} diff --git a/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/DelegatingAuthorizationGrantAuthenticationConverter.java b/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/DelegatingAuthorizationGrantAuthenticationConverter.java new file mode 100644 index 0000000..30cf0d8 --- /dev/null +++ b/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/DelegatingAuthorizationGrantAuthenticationConverter.java @@ -0,0 +1,61 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.web; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.Map; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * A {@link Converter} that delegates actual conversion to one of the provided converters based on grant_type param of a request. + * Returns null is grant type is not specified or not supported. + * + * @author Alexey Nesterov + * @since 0.0.1 + */ +public final class DelegatingAuthorizationGrantAuthenticationConverter implements Converter { + + private final Map> converters; + + public DelegatingAuthorizationGrantAuthenticationConverter(Map> converters) { + Assert.notEmpty(converters, "converters cannot be empty"); + + this.converters = Collections.unmodifiableMap(converters); + } + + @Override + public Authentication convert(HttpServletRequest request) { + String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + if (StringUtils.isEmpty(grantType)) { + return null; + } + + Converter converter = this.converters.get(new AuthorizationGrantType(grantType)); + if (converter == null) { + return null; + } + + return converter.convert(request); + } +} diff --git a/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java b/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java index 51edd88..7356f65 100644 --- a/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java +++ b/core/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java @@ -35,6 +35,8 @@ import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMe import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; @@ -48,6 +50,11 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; /** * A {@code Filter} for the OAuth 2.0 Authorization Code Grant, @@ -86,8 +93,8 @@ public class OAuth2TokenEndpointFilter extends OncePerRequestFilter { private final AuthenticationManager authenticationManager; private final OAuth2AuthorizationService authorizationService; private final RequestMatcher tokenEndpointMatcher; - private final Converter authorizationGrantAuthenticationConverter = - new AuthorizationCodeAuthenticationConverter(); + private final Converter authorizationGrantAuthenticationConverter; + private final HttpMessageConverter accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter(); private final HttpMessageConverter errorHttpResponseConverter = @@ -119,6 +126,11 @@ public class OAuth2TokenEndpointFilter extends OncePerRequestFilter { this.authenticationManager = authenticationManager; this.authorizationService = authorizationService; this.tokenEndpointMatcher = new AntPathRequestMatcher(tokenEndpointUri, HttpMethod.POST.name()); + + Map> converters = new HashMap<>(); + converters.put(AuthorizationGrantType.AUTHORIZATION_CODE, new AuthorizationCodeAuthenticationConverter()); + converters.put(AuthorizationGrantType.CLIENT_CREDENTIALS, new ClientCredentialsAuthenticationConverter()); + this.authorizationGrantAuthenticationConverter = new DelegatingAuthorizationGrantAuthenticationConverter(converters); } @Override @@ -131,8 +143,16 @@ public class OAuth2TokenEndpointFilter extends OncePerRequestFilter { } try { - Authentication authorizationGrantAuthentication = - this.authorizationGrantAuthenticationConverter.convert(request); + String[] grantTypes = request.getParameterValues(OAuth2ParameterNames.GRANT_TYPE); + if (grantTypes == null || grantTypes.length == 0) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, "grant_type"); + } + + Authentication authorizationGrantAuthentication = this.authorizationGrantAuthenticationConverter.convert(request); + if (authorizationGrantAuthentication == null) { + throwError(OAuth2ErrorCodes.UNSUPPORTED_GRANT_TYPE, "grant_type"); + } + OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationManager.authenticate(authorizationGrantAuthentication); sendAccessTokenResponse(response, accessTokenAuthentication.getAccessToken()); @@ -161,7 +181,7 @@ public class OAuth2TokenEndpointFilter extends OncePerRequestFilter { this.errorHttpResponseConverter.write(error, null, httpResponse); } - private static OAuth2AuthenticationException throwError(String errorCode, String parameterName) { + private static void throwError(String errorCode, String parameterName) { OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, "https://tools.ietf.org/html/rfc6749#section-5.2"); throw new OAuth2AuthenticationException(error); @@ -214,4 +234,29 @@ public class OAuth2TokenEndpointFilter extends OncePerRequestFilter { new OAuth2AuthorizationCodeAuthenticationToken(code, clientId, redirectUri); } } + + private static class ClientCredentialsAuthenticationConverter implements Converter { + + @Override + public Authentication convert(HttpServletRequest request) { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + final OAuth2ClientAuthenticationToken clientAuthenticationToken = (OAuth2ClientAuthenticationToken) authentication; + + // grant_type (REQUIRED) + String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + if (!AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(grantType)) { + throwError(OAuth2ErrorCodes.UNSUPPORTED_GRANT_TYPE, OAuth2ParameterNames.GRANT_TYPE); + } + + // scope (OPTIONAL) + // https://tools.ietf.org/html/rfc6749#section-4.4.2 + String scopeParameter = request.getParameter(OAuth2ParameterNames.SCOPE); + if (StringUtils.isEmpty(scopeParameter)) { + return new OAuth2ClientCredentialsAuthenticationToken(clientAuthenticationToken); + } + + Set requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scopeParameter, " "))); + return new OAuth2ClientCredentialsAuthenticationToken(clientAuthenticationToken, requestedScopes); + } + } } diff --git a/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java b/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java new file mode 100644 index 0000000..621d027 --- /dev/null +++ b/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Alexey Nesterov + */ +public class OAuth2ClientCredentialsAuthenticationProviderTests { + + private static final RegisteredClient EXISTING_CLIENT = TestRegisteredClients.registeredClient().build(); + private OAuth2ClientCredentialsAuthenticationProvider authenticationProvider; + + @Before + public void setUp() { + this.authenticationProvider = new OAuth2ClientCredentialsAuthenticationProvider(); + } + + @Test + public void supportsWhenSupportedClassThenTrue() { + assertThat(this.authenticationProvider.supports(OAuth2ClientCredentialsAuthenticationToken.class)).isTrue(); + } + + @Test + public void supportsWhenUnsupportedClassThenFalse() { + assertThat(this.authenticationProvider.supports(OAuth2AuthorizationCodeAuthenticationProvider.class)).isFalse(); + } + + @Test + public void authenticateWhenValidAuthenticationThenReturnTokenWithClient() { + Authentication authentication = this.authenticationProvider.authenticate(getAuthentication()); + assertThat(authentication).isInstanceOf(OAuth2AccessTokenAuthenticationToken.class); + + OAuth2AccessTokenAuthenticationToken token = (OAuth2AccessTokenAuthenticationToken) authentication; + assertThat(token.getRegisteredClient()).isEqualTo(EXISTING_CLIENT); + } + + @Test + public void authenticateWhenValidAuthenticationThenGenerateTokenValue() { + Authentication authentication = this.authenticationProvider.authenticate(getAuthentication()); + OAuth2AccessTokenAuthenticationToken token = (OAuth2AccessTokenAuthenticationToken) authentication; + assertThat(token.getAccessToken().getTokenValue()).isNotBlank(); + } + + @Test + public void authenticateWhenValidateScopeThenReturnTokenWithScopes() { + Authentication authentication = this.authenticationProvider.authenticate(getAuthentication()); + OAuth2AccessTokenAuthenticationToken token = (OAuth2AccessTokenAuthenticationToken) authentication; + assertThat(token.getAccessToken().getScopes()).containsAll(EXISTING_CLIENT.getScopes()); + } + + @Test + public void authenticateWhenNoScopeRequestedThenUseDefaultScopes() { + OAuth2ClientCredentialsAuthenticationToken authenticationToken = new OAuth2ClientCredentialsAuthenticationToken(new OAuth2ClientAuthenticationToken(EXISTING_CLIENT)); + Authentication authentication = this.authenticationProvider.authenticate(authenticationToken); + OAuth2AccessTokenAuthenticationToken token = (OAuth2AccessTokenAuthenticationToken) authentication; + assertThat(token.getAccessToken().getScopes()).containsAll(EXISTING_CLIENT.getScopes()); + } + + @Test + public void authenticateWhenInvalidSecretThenThrowException() { + OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken( + new OAuth2ClientAuthenticationToken(EXISTING_CLIENT.getClientId(), "not-a-valid-secret")); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void authenticateWhenNonExistingClientThenThrowException() { + OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken( + new OAuth2ClientAuthenticationToken("another-client-id", "another-secret")); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void authenticateWhenInvalidScopesThenThrowException() { + OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken( + new OAuth2ClientAuthenticationToken(EXISTING_CLIENT), Collections.singleton("non-existing-scope")); + + assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + private OAuth2ClientCredentialsAuthenticationToken getAuthentication() { + return new OAuth2ClientCredentialsAuthenticationToken(new OAuth2ClientAuthenticationToken(EXISTING_CLIENT), EXISTING_CLIENT.getScopes()); + } +} diff --git a/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationTokenTests.java b/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationTokenTests.java new file mode 100644 index 0000000..c54e872 --- /dev/null +++ b/core/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationTokenTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.Set; + +import org.junit.Test; + +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Alexey Nesterov + */ +public class OAuth2ClientCredentialsAuthenticationTokenTests { + + private final OAuth2ClientAuthenticationToken clientPrincipal = + new OAuth2ClientAuthenticationToken(TestRegisteredClients.registeredClient().build()); + + @Test + public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2ClientCredentialsAuthenticationToken(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("clientPrincipal cannot be null"); + } + + @Test + public void constructorWhenScopesNullThenThrowIllegalArgumentException() { + assertThatThrownBy(() -> new OAuth2ClientCredentialsAuthenticationToken(clientPrincipal, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("scopes cannot be null"); + } + + @Test + public void constructorWhenClientPrincipalProvidedThenCreated() { + OAuth2ClientCredentialsAuthenticationToken authentication + = new OAuth2ClientCredentialsAuthenticationToken(clientPrincipal); + + assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal); + assertThat(authentication.getCredentials().toString()).isEmpty(); + assertThat(authentication.getScopes()).isEmpty(); + } + + @Test + public void constructorWhenScopesProvidedThenCreated() { + Set expectedScopes = Collections.singleton("test-scope"); + + OAuth2ClientCredentialsAuthenticationToken authentication + = new OAuth2ClientCredentialsAuthenticationToken(clientPrincipal, expectedScopes); + + assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal); + assertThat(authentication.getCredentials().toString()).isEmpty(); + assertThat(authentication.getScopes()).containsAll(expectedScopes); + } + +} diff --git a/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/DelegatingAuthorizationGrantAuthenticationConverterTests.java b/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/DelegatingAuthorizationGrantAuthenticationConverterTests.java new file mode 100644 index 0000000..b8326f4 --- /dev/null +++ b/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/DelegatingAuthorizationGrantAuthenticationConverterTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.web; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * @author Alexey Nesterov + */ +public class DelegatingAuthorizationGrantAuthenticationConverterTests { + + private DelegatingAuthorizationGrantAuthenticationConverter authenticationConverter; + private Converter clientCredentialsConverterMock; + + @Before + public void setUp() { + clientCredentialsConverterMock = mock(Converter.class); + Map> converters + = Collections.singletonMap(AuthorizationGrantType.CLIENT_CREDENTIALS, clientCredentialsConverterMock); + authenticationConverter = new DelegatingAuthorizationGrantAuthenticationConverter(converters); + } + + @Test + public void convertWhenAuthorizationGrantTypeSupportedThenConverterCalled() { + MockHttpServletRequest request = MockMvcRequestBuilders + .post("/oauth/token") + .param("grant_type", "client_credentials") + .buildRequest(new MockServletContext()); + + OAuth2ClientAuthenticationToken expectedAuthentication = new OAuth2ClientAuthenticationToken("id", "secret"); + when(clientCredentialsConverterMock.convert(request)).thenReturn(expectedAuthentication); + + Authentication actualAuthentication = authenticationConverter.convert(request); + + verify(clientCredentialsConverterMock).convert(request); + assertThat(actualAuthentication).isEqualTo(expectedAuthentication); + } + + @Test + public void convertWhenAuthorizationGrantTypeNotSupportedThenNull() { + MockHttpServletRequest request = MockMvcRequestBuilders + .post("/oauth/token") + .param("grant_type", "authorization_code") + .buildRequest(new MockServletContext()); + + Authentication actualAuthentication = authenticationConverter.convert(request); + + verifyNoInteractions(clientCredentialsConverterMock); + assertThat(actualAuthentication).isNull(); + } + + @Test + public void convertWhenNoAuthorizationGrantTypeThenNull() { + MockHttpServletRequest request = MockMvcRequestBuilders + .post("/oauth/token") + .buildRequest(new MockServletContext()); + + Authentication actualAuthentication = authenticationConverter.convert(request); + + verifyNoInteractions(clientCredentialsConverterMock); + assertThat(actualAuthentication).isNull(); + } +} diff --git a/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2ClientCredentialsGrantTests.java b/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2ClientCredentialsGrantTests.java new file mode 100644 index 0000000..0249599 --- /dev/null +++ b/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2ClientCredentialsGrantTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.web; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.oauth2.server.authorization.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.config.test.SpringTestRule; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.CoreMatchers.endsWith; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Alexey Nesterov + */ +public class OAuth2ClientCredentialsGrantTests { + + private static RegisteredClientRepository registeredClientRepository; + private static OAuth2AuthorizationService authorizationService; + + @Rule + public final SpringTestRule spring = new SpringTestRule(); + + @Autowired + private MockMvc mvc; + + @BeforeClass + public static void init() { + registeredClientRepository = mock(RegisteredClientRepository.class); + authorizationService = mock(OAuth2AuthorizationService.class); + } + + @Before + public void setup() { + reset(registeredClientRepository); + reset(authorizationService); + } + + @Test + public void requestWhenTokenRequestAuthenticatedThenThenReturnTokenAndScope() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + RegisteredClient client = TestRegisteredClients.registeredClient().build(); + when(registeredClientRepository.findByClientId(client.getClientId())) + .thenReturn(client); + + this.mvc.perform(post(OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI) + .with(httpBasic(client.getClientId(), client.getClientSecret())) + .with(csrf()) + .param("grant_type", "client_credentials") + .param("scope", "email openid")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andExpect(jsonPath("$.scope").value("openid email")); + } + + @Test + public void requestWhenTokenRequestNotAuthenticatedThenRedirect() throws Exception { + this.spring.register(AuthorizationServerConfiguration.class).autowire(); + RegisteredClient client = TestRegisteredClients.registeredClient().build(); + when(registeredClientRepository.findByClientId(client.getClientId())) + .thenReturn(client); + + this.mvc.perform(post(OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI) + .with(csrf()) + .param("grant_type", "client_credentials") + .param("scope", "email openid")) + .andExpect(status().isFound()) + .andExpect(header().string("Location", endsWith("/login"))); + } + + @EnableWebSecurity + @Import(OAuth2AuthorizationServerConfiguration.class) + static class AuthorizationServerConfiguration { + + @Bean + RegisteredClientRepository registeredClientRepository() { + return registeredClientRepository; + } + + @Bean + OAuth2AuthorizationService authorizationService() { + return authorizationService; + } + } +} diff --git a/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java b/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java index 85e2fa6..7c9c440 100644 --- a/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java +++ b/core/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java @@ -19,6 +19,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; + import org.springframework.http.HttpStatus; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.mock.http.client.MockClientHttpResponse; @@ -40,12 +41,15 @@ import org.springframework.security.oauth2.server.authorization.OAuth2Authorizat import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; import javax.servlet.FilterChain; +import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.Arrays; @@ -237,6 +241,69 @@ public class OAuth2TokenEndpointFilterTests { assertThat(accessTokenResult.getScopes()).isEqualTo(accessToken.getScopes()); } + @Test + public void doFilterWhenGrantTypeIsClientCredentialsThenAuthenticateWithClientCredentialsToken() throws ServletException, IOException { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + doFilterForClientCredentialsGrant(registeredClient, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Authentication.class); + verify(this.authenticationManager).authenticate(captor.capture()); + + assertThat(captor.getValue()).isInstanceOf(OAuth2ClientCredentialsAuthenticationToken.class); + OAuth2ClientCredentialsAuthenticationToken clientAuthenticationToken = (OAuth2ClientCredentialsAuthenticationToken) captor.getValue(); + + assertThat(clientAuthenticationToken.getPrincipal()).isEqualTo(new OAuth2ClientAuthenticationToken(registeredClient)); + } + + @Test + public void doFilterWhenGrantTypeIsClientCredentialsWithScopeThenIncludeScopeInResponse() throws ServletException, IOException { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + doFilterForClientCredentialsGrant(registeredClient, "openid email"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Authentication.class); + verify(this.authenticationManager).authenticate(captor.capture()); + + assertThat(captor.getValue()).isInstanceOf(OAuth2ClientCredentialsAuthenticationToken.class); + OAuth2ClientCredentialsAuthenticationToken clientAuthenticationToken = (OAuth2ClientCredentialsAuthenticationToken) captor.getValue(); + + HashSet expectedScopes = new HashSet<>(); + expectedScopes.add("openid"); + expectedScopes.add("email"); + + assertThat(clientAuthenticationToken.getScopes()).isEqualTo(expectedScopes); + } + + private void doFilterForClientCredentialsGrant(RegisteredClient registeredClient, String scope) throws ServletException, IOException { + Authentication clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "token", + Instant.now(), Instant.now().plus(Duration.ofHours(1)), + new HashSet<>(Arrays.asList("scope1", "scope2"))); + OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = + new OAuth2AccessTokenAuthenticationToken( + registeredClient, clientPrincipal, accessToken); + final String clientId = registeredClient.getClientId(); + final String clientSecret = registeredClient.getClientSecret(); + + MockHttpServletRequest request = new MockHttpServletRequest("POST", OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI); + request.setServletPath(OAuth2TokenEndpointFilter.DEFAULT_TOKEN_ENDPOINT_URI); + request.addParameter("client_id", clientId); + request.addParameter("client_secret", clientSecret); + request.addParameter("grant_type", AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()); + if (scope != null) { + request.addParameter("scope", scope); + } + + when(this.authenticationManager.authenticate(any())).thenReturn(accessTokenAuthentication); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new OAuth2ClientAuthenticationToken(registeredClient)); + SecurityContextHolder.setContext(context); + + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, mock(FilterChain.class)); + } + private void doFilterWhenTokenRequestInvalidParameterThenError(String parameterName, String errorCode, Consumer requestConsumer) throws Exception { diff --git a/gradle/dependency-management.gradle b/gradle/dependency-management.gradle index b182633..b439da5 100644 --- a/gradle/dependency-management.gradle +++ b/gradle/dependency-management.gradle @@ -14,5 +14,6 @@ dependencyManagement { dependency 'org.mockito:mockito-core:latest.release' dependency "com.squareup.okhttp3:mockwebserver:3.+" dependency "com.squareup.okhttp3:okhttp:3.+" + dependency "com.jayway.jsonpath:json-path:2.+" } }