From ba8c22eaf01290b0f850c297c11206d63f7179d2 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgilakis Date: Tue, 11 Jul 2023 13:11:54 +0300 Subject: [PATCH] Scope parameter in Oauth 2.0 token exchange Closes #21578 Signed-off-by: cgeorgilakis-grnet --- .../topics/token-exchange/token-exchange.adoc | 5 +- .../oidc/DefaultTokenExchangeProvider.java | 27 +++++ .../oauth/ClientTokenExchangeTest.java | 102 +++++++++++++++++- 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/docs/documentation/securing_apps/topics/token-exchange/token-exchange.adoc b/docs/documentation/securing_apps/topics/token-exchange/token-exchange.adoc index 66a4ee86f2..b6ac9fb6c9 100644 --- a/docs/documentation/securing_apps/topics/token-exchange/token-exchange.adoc +++ b/docs/documentation/securing_apps/topics/token-exchange/token-exchange.adoc @@ -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. diff --git a/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java index c2b6d4bacf..8975517d45 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java @@ -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 targetClientScopes = new HashSet(); + 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 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 subjectTokenScopesSet = Arrays.stream(subjectTokenScopes.split(" ")).collect(Collectors.toSet()); + scope = Arrays.stream(scope.split(" ")).filter(sc -> subjectTokenScopesSet.contains(sc)).collect(Collectors.joining(" ")); + } + + Set targetClientScopes = new HashSet(); + 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: diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java index b8ac280f85..63d3b12ea4 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java @@ -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 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 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 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 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 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 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 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());