Repository: SAP/cloud-security-xsuaa-integration
Pull request #295 (2020-05-28 14:43:27)

Support scope parameter in token-client
Support reducing the scope of created tokens by adding the scope attribute to the request.


Patch:
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenServiceConstants.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenServiceConstants.java
index e66620f0..2b6c062d 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenServiceConstants.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/client/OAuth2TokenServiceConstants.java
@@ -14,6 +14,8 @@ public class OAuth2TokenServiceConstants {
 	public static final String USERNAME = "username";
 	public static final String PASSWORD = "password"; // NOSONAR
 	public static final String ASSERTION = "assertion";
+	public static final String AUTHORITIES = "authorities";
+	public static final String SCOPE = "scope";
 
 	public static final String GRANT_TYPE = "grant_type";
 	public static final String GRANT_TYPE_USER_TOKEN = "user_token";
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/ClientCredentialsTokenFlow.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/ClientCredentialsTokenFlow.java
index ca21c165..20455311 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/ClientCredentialsTokenFlow.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/ClientCredentialsTokenFlow.java
@@ -1,11 +1,14 @@
 package com.sap.cloud.security.xsuaa.tokenflows;
 
 import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull;
+import static com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants.AUTHORITIES;
+import static com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants.SCOPE;
 import static com.sap.cloud.security.xsuaa.tokenflows.XsuaaTokenFlowsUtils.buildAuthorities;
 
-import java.util.HashMap;
-import java.util.Map;
+import java.util.*;
+import java.util.stream.Collectors;
 
+import com.sap.cloud.security.xsuaa.Assertions;
 import com.sap.cloud.security.xsuaa.client.ClientCredentials;
 import com.sap.cloud.security.xsuaa.client.OAuth2TokenResponse;
 import com.sap.cloud.security.xsuaa.client.OAuth2ServiceEndpointsProvider;
@@ -13,6 +16,7 @@ import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
 import com.sap.cloud.security.xsuaa.client.OAuth2TokenService;
 import com.sap.xsa.security.container.XSTokenRequest;
 
+import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
 /**
@@ -22,11 +26,11 @@ import javax.annotation.Nullable;
  */
 public class ClientCredentialsTokenFlow {
 
-	private static final String AUTHORITIES = "authorities";
 
 	private XsuaaTokenFlowRequest request;
 	private OAuth2TokenService tokenService;
 	private boolean disableCache = false;
+	private List<String> scopes = new ArrayList<>();
 
 	/**
 	 * Creates a new instance.
@@ -77,6 +81,24 @@ public class ClientCredentialsTokenFlow {
 		return this;
 	}
 
+	/**
+	 * Sets the scope attribute for the token request. This will restrict the scope
+	 * of the created token to the scopes provided. By default the scope is not
+	 * restricted and the created token contains all granted scopes.
+	 *
+	 * If you specify a scope that is not authorized for the client, the token
+	 * request will fail.
+	 *
+	 * @param scopes
+	 *            - one or many scopes as string.
+	 * @return this builder.
+	 */
+	public ClientCredentialsTokenFlow scopes(@Nonnull String... scopes) {
+		Assertions.assertNotNull(scopes, "Scopes must not be null!");
+		this.scopes = Arrays.asList(scopes);
+		return this;
+	}
+
 	/**
 	 * Can be used to disable the cache for the flow.
 	 * 
@@ -133,14 +155,16 @@ public class ClientCredentialsTokenFlow {
 	 */
 	@Nullable
 	private OAuth2TokenResponse requestTechnicalUserToken(XsuaaTokenFlowRequest request) throws TokenFlowException {
-		Map requestParameter = null;
+		Map<String, String> requestParameter = new HashMap();
 		String authorities = buildAuthorities(request);
 
 		if (authorities != null) {
-			requestParameter = new HashMap();
 			requestParameter.put(AUTHORITIES, authorities); // places JSON inside the URI
 		}
-
+		String scopesParameter = scopes.stream().collect(Collectors.joining(", "));
+		if (!scopesParameter.isEmpty()) {
+			requestParameter.put(SCOPE, scopesParameter);
+		}
 		try {
 			OAuth2TokenResponse accessToken = tokenService
 					.retrieveAccessTokenViaClientCredentialsGrant(request.getTokenEndpoint(),
diff --git a/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/UserTokenFlow.java b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/UserTokenFlow.java
index d398a609..ca2650fc 100644
--- a/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/UserTokenFlow.java
+++ b/token-client/src/main/java/com/sap/cloud/security/xsuaa/tokenflows/UserTokenFlow.java
@@ -1,22 +1,20 @@
 package com.sap.cloud.security.xsuaa.tokenflows;
 
+import static com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants.*;
 import static com.sap.cloud.security.xsuaa.tokenflows.XsuaaTokenFlowsUtils.buildAuthorities;
 
 import java.io.FileInputStream;
 import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Properties;
+import java.util.*;
+import java.util.stream.Collectors;
 
 import static com.sap.cloud.security.xsuaa.Assertions.assertNotNull;
 
+import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
 
-import com.sap.cloud.security.xsuaa.client.ClientCredentials;
-import com.sap.cloud.security.xsuaa.client.OAuth2TokenResponse;
-import com.sap.cloud.security.xsuaa.client.OAuth2ServiceEndpointsProvider;
-import com.sap.cloud.security.xsuaa.client.OAuth2ServiceException;
-import com.sap.cloud.security.xsuaa.client.OAuth2TokenService;
+import com.sap.cloud.security.xsuaa.Assertions;
+import com.sap.cloud.security.xsuaa.client.*;
 import com.sap.xsa.security.container.XSTokenRequest;
 
 /**
@@ -27,12 +25,11 @@ import com.sap.xsa.security.container.XSTokenRequest;
  */
 public class UserTokenFlow {
 
-	private static final String AUTHORITIES = "authorities";
-
 	private XsuaaTokenFlowRequest request;
 	private String token;
 	private OAuth2TokenService tokenService;
 	private boolean disableCache = false;
+	private List<String> scopes = new ArrayList<>();
 
 	/**
 	 * Creates a new instance.
@@ -96,6 +93,24 @@ public class UserTokenFlow {
 		return this;
 	}
 
+	/**
+	 * Sets the scope attribute for the token request. This will restrict the scope
+	 * of the created token to the scopes provided. By default the scope is not
+	 * restricted and the created token contains all granted scopes.
+	 *
+	 * If you specify a scope that is not authorized for the user, the token request
+	 * will fail.
+	 *
+	 * @param scopes
+	 *            - one or many scopes as string.
+	 * @return this builder.
+	 */
+	public UserTokenFlow scopes(@Nonnull String... scopes) {
+		Assertions.assertNotNull(scopes, "Scopes must not be null!");
+		this.scopes = Arrays.asList(scopes);
+		return this;
+	}
+
 	/**
 	 * Can be used to disable the cache for the flow.
 	 *
@@ -160,14 +175,18 @@ public class UserTokenFlow {
 	 *             in case of an error during the flow.
 	 */
 	private OAuth2TokenResponse requestUserToken(XsuaaTokenFlowRequest request) throws TokenFlowException {
-		Map<String, String> optionalParameter = null;
+		Map<String, String> optionalParameter = new HashMap<>();
 		String authorities = buildAuthorities(request);
 
 		if (authorities != null) {
-			optionalParameter = new HashMap<>();
 			optionalParameter.put(AUTHORITIES, authorities); // places JSON inside the URI !?!
 		}
 
+		String scopesParameter = scopes.stream().collect(Collectors.joining(", "));
+		if (!scopesParameter.isEmpty()) {
+			optionalParameter.put(SCOPE, scopesParameter);
+		}
+
 		try {
 			return tokenService.retrieveAccessTokenViaJwtBearerTokenGrant(
 					request.getTokenEndpoint(),


Test patch:
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java
index 1effae4a..4c691a26 100644
--- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/client/DefaultOAuth2TokenServiceTest.java
@@ -3,25 +3,31 @@ package com.sap.cloud.security.xsuaa.client;
 import com.sap.cloud.security.xsuaa.http.HttpHeaders;
 import com.sap.cloud.security.xsuaa.http.HttpHeadersFactory;
 import com.sap.cloud.security.xsuaa.util.HttpClientTestFactory;
+import org.apache.commons.io.IOUtils;
+import org.apache.http.HttpEntity;
 import org.apache.http.HttpStatus;
 import org.apache.http.client.methods.CloseableHttpResponse;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.impl.client.CloseableHttpClient;
+import org.assertj.core.util.Maps;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 import org.mockito.junit.MockitoJUnitRunner;
 
 import java.io.IOException;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.*;
 
 @RunWith(MockitoJUnitRunner.class)
 public class DefaultOAuth2TokenServiceTest {
@@ -29,7 +35,7 @@ public class DefaultOAuth2TokenServiceTest {
 	private static final String ACCESS_TOKEN = "abc123";
 	private static final String REFRESH_TOKEN = "def456";
 	private static final String VALID_JSON_RESPONSE = String
-			.format("{expires_in: 1234, access_token: %s, refresh_token: %s}",
+			.format("{expires_in: 10000, access_token: %s, refresh_token: %s}",
 					ACCESS_TOKEN, REFRESH_TOKEN);
 
 	private CloseableHttpClient mockHttpClient;
@@ -46,39 +52,37 @@ public class DefaultOAuth2TokenServiceTest {
 		CloseableHttpResponse response = HttpClientTestFactory.createHttpResponse("{}");
 		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
 
-		assertThatThrownBy(() -> requestAccessToken())
+		assertThatThrownBy(() -> requestAccessToken(Collections.emptyMap()))
 				.isInstanceOf(OAuth2ServiceException.class)
 				.hasMessageContaining("expires_in");
 	}
 
 	@Test
-	public void httpResponseWithExpiresIn_yieldsExpiresInTokenResponse() throws IOException {
+	public void execute_yieldsTokenResponseWithCorrectData() throws IOException {
 		CloseableHttpResponse response = HttpClientTestFactory.createHttpResponse(VALID_JSON_RESPONSE);
 		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
 
-		OAuth2TokenResponse re = requestAccessToken();
-
-		assertThat(re.getExpiredAtDate()).isNotNull();
-	}
-
-	@Test
-	public void httpResponseWithToken_yieldsTokenInTokenResponse() throws IOException {
-		CloseableHttpResponse response = HttpClientTestFactory.createHttpResponse(VALID_JSON_RESPONSE);
-		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
-
-		OAuth2TokenResponse re = requestAccessToken();
+		OAuth2TokenResponse re = requestAccessToken(Collections.emptyMap());
 
 		assertThat(re.getAccessToken()).isEqualTo(ACCESS_TOKEN);
+		assertThat(re.getRefreshToken()).isEqualTo(REFRESH_TOKEN);
+		assertThat(re.getExpiredAt()).isAfter(Instant.now());
 	}
 
 	@Test
-	public void httpResponseWithRefreshToken_yieldsTokenInTokenResponse() throws IOException {
+	public void executeWithAdditionalParameters_putsParametersIntoPostBody() throws IOException {
+		ArgumentCaptor<HttpPost> httpPostCaptor = ArgumentCaptor.forClass(HttpPost.class);
 		CloseableHttpResponse response = HttpClientTestFactory.createHttpResponse(VALID_JSON_RESPONSE);
 		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
 
-		OAuth2TokenResponse re = requestAccessToken();
+		requestAccessToken(Maps.newHashMap("myKey", "myValue"));
 
-		assertThat(re.getRefreshToken()).isEqualTo(REFRESH_TOKEN);
+		verify(mockHttpClient, times(1)).execute(httpPostCaptor.capture());
+		HttpPost httpPost = httpPostCaptor.getValue();
+		HttpEntity httpEntity = httpPost.getEntity();
+		assertThat(httpEntity).isNotNull();
+		String postBody = IOUtils.toString(httpEntity.getContent(), StandardCharsets.UTF_8);
+		assertThat(postBody).contains("myKey=myValue");
 	}
 
 	@Test
@@ -88,15 +92,14 @@ public class DefaultOAuth2TokenServiceTest {
 				.createHttpResponse(unauthorizedResponseText, HttpStatus.SC_UNAUTHORIZED);
 		when(mockHttpClient.execute(any(HttpPost.class))).thenReturn(response);
 
-		assertThatThrownBy(() -> requestAccessToken())
+		assertThatThrownBy(() -> requestAccessToken(Collections.emptyMap()))
 				.isInstanceOf(OAuth2ServiceException.class)
 				.hasMessageContaining(unauthorizedResponseText);
 	}
 
-	private OAuth2TokenResponse requestAccessToken() throws OAuth2ServiceException {
+	private OAuth2TokenResponse requestAccessToken(Map<String, String> parameters) throws OAuth2ServiceException {
 		URI tokenEndpointUri = URI.create("https://subdomain.myauth.server.com/oauth/token");
 		HttpHeaders withoutAuthorizationHeader = HttpHeadersFactory.createWithoutAuthorizationHeader();
-		Map<String, String> parameters = Collections.emptyMap();
 		return cut.requestAccessToken(tokenEndpointUri, withoutAuthorizationHeader, parameters);
 	}
 
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/tokenflows/ClientCredentialsTokenFlowTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/tokenflows/ClientCredentialsTokenFlowTest.java
index ed40e68f..1df55dc2 100644
--- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/tokenflows/ClientCredentialsTokenFlowTest.java
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/tokenflows/ClientCredentialsTokenFlowTest.java
@@ -1,9 +1,11 @@
 package com.sap.cloud.security.xsuaa.tokenflows;
 
+import static com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants.AUTHORITIES;
+import static com.sap.cloud.security.xsuaa.client.OAuth2TokenServiceConstants.SCOPE;
 import static com.sap.cloud.security.xsuaa.tokenflows.TestConstants.*;
+import static java.util.Collections.*;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.hamcrest.CoreMatchers.is;
-import static org.hamcrest.MatcherAssert.assertThat;
 import static org.mockito.ArgumentMatchers.*;
 import static org.mockito.Mockito.*;
 import static org.mockito.Mockito.times;
@@ -14,6 +16,7 @@ import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.MockitoJUnitRunner;
 
@@ -64,15 +67,18 @@ public class ClientCredentialsTokenFlowTest {
 
 		OAuth2TokenResponse response = cut.execute();
 
-		assertThat(response.getAccessToken(), is(accessToken.getAccessToken()));
-		verifyThatDisableCacheIs(false);
+		assertThat(response.getAccessToken()).isSameAs(accessToken.getAccessToken());
+		verifyThatDisableCacheAttributeIs(false);
+		verify(mockTokenService, times(1))
+				.retrieveAccessTokenViaClientCredentialsGrant(endpointsProvider.getTokenEndpoint(),
+						clientCredentials, null, emptyMap(), false);
 	}
 
 	@Test
 	public void execute_throwsIfServiceRaisesException() throws OAuth2ServiceException {
 		when(mockTokenService
 				.retrieveAccessTokenViaClientCredentialsGrant(eq(TOKEN_ENDPOINT_URI), eq(clientCredentials),
-						isNull(), isNull(), anyBoolean()))
+						isNull(), anyMap(), anyBoolean()))
 								.thenThrow(new OAuth2ServiceException("exception executed REST call"));
 
 		assertThatThrownBy(() -> {
@@ -84,16 +90,45 @@ public class ClientCredentialsTokenFlowTest {
 
 	@Test
 	public void execute_withAdditionalAuthorities() throws TokenFlowException, OAuth2ServiceException {
+		ArgumentCaptor<Map<String, String>> optionalParametersCaptor = ArgumentCaptor.forClass(Map.class);
+
 		OAuth2TokenResponse accessToken = mockRetrieveAccessToken();
 		Map<String, String> additionalAuthorities = new HashMap<>();
 		additionalAuthorities.put("DummyAttribute", "DummyAttributeValue");
 
 		OAuth2TokenResponse response = cut.attributes(additionalAuthorities).execute();
 
-		assertThat(response.getAccessToken(), is(accessToken.getAccessToken()));
+		assertThat(response.getAccessToken()).isSameAs(accessToken.getAccessToken());
+		verify(mockTokenService, times(1))
+				.retrieveAccessTokenViaClientCredentialsGrant(eq(TOKEN_ENDPOINT_URI), eq(clientCredentials),
+						isNull(), optionalParametersCaptor.capture(), anyBoolean());
+		Map<String, String> optionalParameters = optionalParametersCaptor.getValue();
+
+		assertThat(optionalParameters).containsKey(AUTHORITIES);
+		assertThat(optionalParameters.get(AUTHORITIES)).isNotEmpty();
+	}
+
+	@Test
+	public void execute_WithScopes() throws OAuth2ServiceException, TokenFlowException {
+		ArgumentCaptor<Map<String, String>> optionalParametersCaptor = ArgumentCaptor.forClass(Map.class);
+
+		OAuth2TokenResponse accessToken = mockRetrieveAccessToken();
+
+		OAuth2TokenResponse response = cut.scopes("myFirstScope", "theOtherScope").execute();
+		assertThat(response.getAccessToken()).isSameAs(accessToken.getAccessToken());
+
 		verify(mockTokenService, times(1))
 				.retrieveAccessTokenViaClientCredentialsGrant(eq(TOKEN_ENDPOINT_URI), eq(clientCredentials),
-						isNull(), isNotNull(), anyBoolean());
+						isNull(), optionalParametersCaptor.capture(), anyBoolean());
+
+		Map<String, String> optionalParameters = optionalParametersCaptor.getValue();
+		assertThat(optionalParameters).containsKey(SCOPE);
+		assertThat(optionalParameters.get(SCOPE)).isEqualTo("myFirstScope, theOtherScope");
+	}
+
+	@Test
+	public void execute_withScopesSetToNull_throwsException() throws OAuth2ServiceException, TokenFlowException {
+		assertThatThrownBy(() -> cut.scopes(null)).isInstanceOf(IllegalArgumentException.class);
 	}
 
 	@Test
@@ -102,19 +137,19 @@ public class ClientCredentialsTokenFlowTest {
 
 		OAuth2TokenResponse response = cut.disableCache(true).execute();
 
-		assertThat(response.getAccessToken(), is(accessToken.getAccessToken()));
-		verifyThatDisableCacheIs(true);
+		assertThat(response.getAccessToken()).isSameAs(accessToken.getAccessToken());
+		verifyThatDisableCacheAttributeIs(true);
 
 		cut.disableCache(false).execute();
 
-		verifyThatDisableCacheIs(false);
+		verifyThatDisableCacheAttributeIs(false);
 
 	}
 
-	private void verifyThatDisableCacheIs(boolean disableCache) throws OAuth2ServiceException {
+	private void verifyThatDisableCacheAttributeIs(boolean disableCache) throws OAuth2ServiceException {
 		verify(mockTokenService, times(1))
 				.retrieveAccessTokenViaClientCredentialsGrant(eq(TOKEN_ENDPOINT_URI), eq(clientCredentials),
-						isNull(), isNull(), eq(disableCache));
+						isNull(), anyMap(), eq(disableCache));
 	}
 
 	private OAuth2TokenResponse mockRetrieveAccessToken() throws OAuth2ServiceException {
diff --git a/token-client/src/test/java/com/sap/cloud/security/xsuaa/tokenflows/UserTokenFlowTest.java b/token-client/src/test/java/com/sap/cloud/security/xsuaa/tokenflows/UserTokenFlowTest.java
index b3085c02..163aa9e4 100644
--- a/token-client/src/test/java/com/sap/cloud/security/xsuaa/tokenflows/UserTokenFlowTest.java
+++ b/token-client/src/test/java/com/sap/cloud/security/xsuaa/tokenflows/UserTokenFlowTest.java
@@ -1,9 +1,9 @@
 package com.sap.cloud.security.xsuaa.tokenflows;
 
 import static com.sap.cloud.security.xsuaa.tokenflows.TestConstants.*;
+import static java.util.Collections.*;
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.hamcrest.CoreMatchers.is;
-import static org.hamcrest.MatcherAssert.assertThat;
 import static org.mockito.ArgumentMatchers.*;
 import static org.mockito.Mockito.*;
 import static org.mockito.Mockito.times;
@@ -15,6 +15,7 @@ import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.junit.MockitoJUnitRunner;
 
 import com.sap.cloud.security.xsuaa.client.ClientCredentials;
@@ -69,7 +70,7 @@ public class UserTokenFlowTest {
 	@Test
 	public void execute_throwsIfServiceRaisesException() throws OAuth2ServiceException {
 		when(mockTokenService
-				.retrieveAccessTokenViaJwtBearerTokenGrant(any(), any(), any(), isNull(), isNull(), anyBoolean()))
+				.retrieveAccessTokenViaJwtBearerTokenGrant(any(), any(), any(), isNull(), any(), anyBoolean()))
 						.thenThrow(new OAuth2ServiceException("exception executed REST call"));
 
 		assertThatThrownBy(() -> cut.token(exchangeToken).execute())
@@ -84,11 +85,11 @@ public class UserTokenFlowTest {
 
 		OAuth2TokenResponse response = cut.token(exchangeToken).execute();
 
-		assertThat(response.getAccessToken(), is(mockedResponse.getAccessToken()));
+		assertThat(response.getAccessToken()).isSameAs(mockedResponse.getAccessToken());
 		verify(mockTokenService, times(1))
 				.retrieveAccessTokenViaJwtBearerTokenGrant(endpointsProvider.getTokenEndpoint(),
 						clientCredentials, exchangeToken, null,
-						null, false);
+						emptyMap(), false);
 	}
 
 	@Test
@@ -98,18 +99,41 @@ public class UserTokenFlowTest {
 
 		OAuth2TokenResponse response = cut.subdomain(subdomain).token(exchangeToken).execute();
 
-		assertThat(response.getAccessToken(), is(mockedResponse.getAccessToken()));
+		assertThat(response.getAccessToken()).isSameAs(mockedResponse.getAccessToken());
+
 		verify(mockTokenService, times(1))
 				.retrieveAccessTokenViaJwtBearerTokenGrant(any(), any(), any(), eq(subdomain), any(), anyBoolean());
 	}
 
+	@Test
+	public void execute_withScopes() throws TokenFlowException, OAuth2ServiceException {
+		ArgumentCaptor<Map<String, String>> optionalParametersCaptor = ArgumentCaptor.forClass(Map.class);
+		OAuth2TokenResponse mockedResponse = mockRetrieveAccessToken();
+
+		OAuth2TokenResponse response = cut.scopes("scope1", "scope2").token(exchangeToken).execute();
+
+		assertThat(response.getAccessToken()).isSameAs(mockedResponse.getAccessToken());
+		verify(mockTokenService, times(1))
+				.retrieveAccessTokenViaJwtBearerTokenGrant(any(), any(), any(), any(),
+						optionalParametersCaptor.capture(), anyBoolean());
+
+		Map<String, String> optionalParameters = optionalParametersCaptor.getValue();
+		assertThat(optionalParameters).containsKey("scope");
+		assertThat(optionalParameters.get("scope")).isEqualTo("scope1, scope2");
+	}
+
+	@Test
+	public void execute_withScopesSetToNull_throwsException() {
+		assertThatThrownBy(() -> cut.scopes(null)).isInstanceOf(IllegalArgumentException.class);
+	}
+
 	@Test
 	public void execute_withDisableCache() throws TokenFlowException, OAuth2ServiceException {
 		OAuth2TokenResponse mockedResponse = mockRetrieveAccessToken();
 
 		OAuth2TokenResponse response = cut.disableCache(true).token(exchangeToken).execute();
 
-		assertThat(response.getAccessToken(), is(mockedResponse.getAccessToken()));
+		assertThat(response.getAccessToken()).isSameAs(mockedResponse.getAccessToken());
 		verify(mockTokenService, times(1))
 				.retrieveAccessTokenViaJwtBearerTokenGrant(any(), any(), any(), any(), any(), eq(true));
 
@@ -132,7 +156,7 @@ public class UserTokenFlowTest {
 				.attributes(additionalAuthorities)
 				.execute();
 
-		assertThat(actualResponse.getAccessToken(), is(mockedResponse.getAccessToken()));
+		assertThat(actualResponse.getAccessToken()).isSameAs(mockedResponse.getAccessToken());
 		verify(mockTokenService, times(1))
 				.retrieveAccessTokenViaJwtBearerTokenGrant(eq(TOKEN_ENDPOINT_URI), eq(clientCredentials),
 						eq(exchangeToken),