Scope parameter in Oauth 2.0 token exchange

Closes #21578

Signed-off-by: cgeorgilakis-grnet <cgeorgilakis@admin.grnet.gr>
This commit is contained in:
Konstantinos Georgilakis 2023-07-11 13:11:54 +03:00 committed by Pedro Igor
parent 778847a3ce
commit ba8c22eaf0
3 changed files with 130 additions and 4 deletions

View file

@ -76,9 +76,8 @@ requested_issuer::
requested_subject::
_OPTIONAL._ This specifies a username or user id if your client wants to impersonate a different user.
scope::
_NOT IMPLEMENTED._ This parameter represents the target set of OAuth and OpenID Connect scopes the client
is requesting. It is not implemented at this time but will be once {project_name} has better support for
scopes in general.
_OPTIONAL._ This parameter represents the target set of OAuth and OpenID Connect scopes the client
is requesting. Returned scope is the Cartesian product of scope parameter and access token scope.
NOTE: We currently only support OpenID Connect and OAuth exchanges. Support for SAML based clients and identity providers may be added in the future depending on user demand.

View file

@ -28,6 +28,7 @@ import org.keycloak.broker.provider.IdentityProviderFactory;
import org.keycloak.broker.provider.IdentityProviderMapper;
import org.keycloak.broker.provider.IdentityProviderMapperSyncModeDelegate;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.Base64Url;
import org.keycloak.events.Details;
@ -77,6 +78,8 @@ import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_CLIENT;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -337,6 +340,30 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
}
String scope = formParams.getFirst(OAuth2Constants.SCOPE);
if (token != null && token.getScope() != null && scope == null) {
scope = token.getScope();
Set<String> targetClientScopes = new HashSet<String>();
targetClientScopes.addAll(targetClient.getClientScopes(true).keySet());
targetClientScopes.addAll(targetClient.getClientScopes(false).keySet());
//from return scope remove scopes that are not default or optional scopes for targetClient
scope = Arrays.stream(scope.split(" ")).filter(s -> "openid".equals(s) || (targetClientScopes.contains(Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES) ? s.split(":")[0] : s))).collect(Collectors.joining(" "));
} else if (token != null && token.getScope() != null) {
String subjectTokenScopes = token.getScope();
if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) {
Set<String> subjectTokenScopesSet = Arrays.stream(subjectTokenScopes.split(" ")).map(s -> s.split(":")[0]).collect(Collectors.toSet());
scope = Arrays.stream(scope.split(" ")).filter(sc -> subjectTokenScopesSet.contains(sc.split(":")[0])).collect(Collectors.joining(" "));
} else {
Set<String> subjectTokenScopesSet = Arrays.stream(subjectTokenScopes.split(" ")).collect(Collectors.toSet());
scope = Arrays.stream(scope.split(" ")).filter(sc -> subjectTokenScopesSet.contains(sc)).collect(Collectors.joining(" "));
}
Set<String> targetClientScopes = new HashSet<String>();
targetClientScopes.addAll(targetClient.getClientScopes(true).keySet());
targetClientScopes.addAll(targetClient.getClientScopes(false).keySet());
//from return scope remove scopes that are not default or optional scopes for targetClient
scope = Arrays.stream(scope.split(" ")).filter(s -> "openid".equals(s) || (targetClientScopes.contains(Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES) ? s.split(":")[0] : s))).collect(Collectors.joining(" "));
}
switch (requestedTokenType) {
case OAuth2Constants.ACCESS_TOKEN_TYPE:

View file

@ -61,6 +61,8 @@ import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.Form;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@ -128,6 +130,16 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
RoleModel impersonateRole = management.getRealmManagementClient().getRole(ImpersonationConstants.IMPERSONATION_ROLE);
ClientModel differentScopeClient = realm.addClient("different-scope-client");
differentScopeClient.setClientId("different-scope-client");
differentScopeClient.setPublicClient(false);
differentScopeClient.setDirectAccessGrantsEnabled(true);
differentScopeClient.setEnabled(true);
differentScopeClient.setSecret("secret");
differentScopeClient.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
differentScopeClient.setFullScopeAllowed(false);
differentScopeClient.removeClientScope(realm.getClientScopesStream().filter(scope->"email".equals(scope.getName())).findAny().get());
ClientModel clientExchanger = realm.addClient("client-exchanger");
clientExchanger.setClientId("client-exchanger");
clientExchanger.setPublicClient(false);
@ -139,6 +151,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
clientExchanger.addScopeMapping(impersonateRole);
clientExchanger.addProtocolMapper(UserSessionNoteMapper.createUserSessionNoteMapper(IMPERSONATOR_ID));
clientExchanger.addProtocolMapper(UserSessionNoteMapper.createUserSessionNoteMapper(IMPERSONATOR_USERNAME));
clientExchanger.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("different-scope-client-audience", differentScopeClient.getClientId(), null, true, false, true));
ClientModel illegal = realm.addClient("illegal");
illegal.setClientId("illegal");
@ -223,6 +236,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
clientRep.addClient(directLegal.getId());
clientRep.addClient(noRefreshToken.getId());
clientRep.addClient(serviceAccount.getId());
clientRep.addClient(differentScopeClient.getId());
ResourceServer server = management.realmResourceServer();
Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep);
@ -333,6 +347,92 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
}
}
@Test
@UncaughtServerErrorExpected
public void testExchangeDifferentScopes() throws Exception {
testingClient.server().run(ClientTokenExchangeTest::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
Assert.assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
{
response = oauth.doTokenExchange(TEST, accessToken, null, "different-scope-client", "secret");
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("different-scope-client", exchangedToken.getIssuedFor());
Assert.assertNull(exchangedToken.getAudience());
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
Assert.assertNames(Arrays.asList(exchangedToken.getScope().split(" ")),"profile","openid");
Assert.assertNull(exchangedToken.getEmailVerified());
}
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "different-scope-client", "secret");
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("different-scope-client", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
Assert.assertNames(Arrays.asList(exchangedToken.getScope().split(" ")),"profile","email","openid");
Assert.assertFalse(exchangedToken.getEmailVerified());
}
}
@Test
@UncaughtServerErrorExpected
public void testExchangeDifferentScopesWithScopeParameter() throws Exception {
testingClient.server().run(ClientTokenExchangeTest::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
oauth.scope("openid profile email phone");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
TokenVerifier<AccessToken> accessTokenVerifier = TokenVerifier.create(accessToken, AccessToken.class);
AccessToken token = accessTokenVerifier.parse().getToken();
Assert.assertEquals(token.getPreferredUsername(), "user");
Assert.assertTrue(token.getRealmAccess() == null || !token.getRealmAccess().isUserInRole("example"));
Assert.assertNames(Arrays.asList(token.getScope().split(" ")),"profile", "email", "openid", "phone");
//change scopes for token exchange - profile,phone must be removed
oauth.scope("openid profile email");
{
response = oauth.doTokenExchange(TEST, accessToken, null, "different-scope-client", "secret");
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("different-scope-client", exchangedToken.getIssuedFor());
Assert.assertNull(exchangedToken.getAudience());
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
Assert.assertNames(Arrays.asList(exchangedToken.getScope().split(" ")),"profile", "openid");
Assert.assertNull(exchangedToken.getEmailVerified());
}
{
response = oauth.doTokenExchange(TEST, accessToken, "target", "different-scope-client", "secret");
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("different-scope-client", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
Assert.assertNames(Arrays.asList(exchangedToken.getScope().split(" ")),"profile", "email","openid");
Assert.assertFalse(exchangedToken.getEmailVerified());
}
oauth.scope(null);
}
@Test
@UncaughtServerErrorExpected
public void testExchangeFromPublicClient() throws Exception {
@ -413,7 +513,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor());
Assert.assertNull(exchangedToken.getAudience());
assertNotNull(exchangedToken.getAudience());
Assert.assertEquals("impersonated-user", exchangedToken.getPreferredUsername());
Assert.assertNull(exchangedToken.getRealmAccess());