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 77ccb29f2..ef3ac5fcf 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.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 void init(B builder) {
new OAuth2AuthorizationCodeAuthenticationProvider(
getRegisteredClientRepository(builder),
getAuthorizationService(builder));
+
+ OAuth2ClientCredentialsAuthenticationProvider clientCredentialsAuthenticationProvider
+ = new OAuth2ClientCredentialsAuthenticationProvider();
+
builder.authenticationProvider(postProcess(authorizationCodeAuthenticationProvider));
+ builder.authenticationProvider(postProcess(clientCredentialsAuthenticationProvider));
}
@Override
diff --git a/core/spring-authorization-server-core.gradle b/core/spring-authorization-server-core.gradle
index 83b637699..1d1df0567 100644
--- a/core/spring-authorization-server-core.gradle
+++ b/core/spring-authorization-server-core.gradle
@@ -17,6 +17,7 @@ dependencies {
testCompile 'org.assertj:assertj-core'
testCompile 'org.mockito:mockito-core'
testCompile 'com.squareup.okhttp3:mockwebserver'
+ testCompile 'com.jayway.jsonpath:json-path'
provided 'javax.servlet:javax.servlet-api'
}
diff --git a/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProvider.java b/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProvider.java
new file mode 100644
index 000000000..4037717b4
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProvider.java
@@ -0,0 +1,85 @@
+/*
+ * 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.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Base64;
+import java.util.Set;
+
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.crypto.keygen.Base64StringKeyGenerator;
+import org.springframework.security.crypto.keygen.StringKeyGenerator;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+
+/**
+ * An {@link AuthenticationProvider} implementation for the OAuth 2.0 Client Credentials Grant.
+ *
+ * @author Alexey Nesterov
+ * @since 0.0.1
+ * @see OAuth2ClientCredentialsAuthenticationToken
+ * @see Section 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 000000000..575cb0d2c
--- /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 000000000..30cf0d8fd
--- /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 51edd88f8..7356f65b1 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.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.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 OAuth2TokenEndpointFilter(AuthenticationManager authenticationManager,
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 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
}
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 @@ private void sendErrorResponse(HttpServletResponse response, OAuth2Error error)
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 Authentication convert(HttpServletRequest request) {
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 000000000..621d027db
--- /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 000000000..c54e872ee
--- /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 000000000..b8326f4ee
--- /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 000000000..024959934
--- /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 85e2fa629..7c9c440f9 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.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.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 void doFilterWhenTokenRequestValidThenAccessTokenResponse() throws Except
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 b1826336b..b439da54b 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.+"
}
}