Scope parameter in Oauth 2.0 token exchange
Closes #21578 Signed-off-by: cgeorgilakis-grnet <cgeorgilakis@admin.grnet.gr>
This commit is contained in:
parent
778847a3ce
commit
ba8c22eaf0
3 changed files with 130 additions and 4 deletions
|
@ -76,9 +76,8 @@ requested_issuer::
|
||||||
requested_subject::
|
requested_subject::
|
||||||
_OPTIONAL._ This specifies a username or user id if your client wants to impersonate a different user.
|
_OPTIONAL._ This specifies a username or user id if your client wants to impersonate a different user.
|
||||||
scope::
|
scope::
|
||||||
_NOT IMPLEMENTED._ This parameter represents the target set of OAuth and OpenID Connect scopes the client
|
_OPTIONAL._ 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
|
is requesting. Returned scope is the Cartesian product of scope parameter and access token scope.
|
||||||
scopes in general.
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@ import org.keycloak.broker.provider.IdentityProviderFactory;
|
||||||
import org.keycloak.broker.provider.IdentityProviderMapper;
|
import org.keycloak.broker.provider.IdentityProviderMapper;
|
||||||
import org.keycloak.broker.provider.IdentityProviderMapperSyncModeDelegate;
|
import org.keycloak.broker.provider.IdentityProviderMapperSyncModeDelegate;
|
||||||
import org.keycloak.common.ClientConnection;
|
import org.keycloak.common.ClientConnection;
|
||||||
|
import org.keycloak.common.Profile;
|
||||||
import org.keycloak.common.constants.ServiceAccountConstants;
|
import org.keycloak.common.constants.ServiceAccountConstants;
|
||||||
import org.keycloak.common.util.Base64Url;
|
import org.keycloak.common.util.Base64Url;
|
||||||
import org.keycloak.events.Details;
|
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_ID;
|
||||||
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
|
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
@ -337,6 +340,30 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
String scope = formParams.getFirst(OAuth2Constants.SCOPE);
|
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) {
|
switch (requestedTokenType) {
|
||||||
case OAuth2Constants.ACCESS_TOKEN_TYPE:
|
case OAuth2Constants.ACCESS_TOKEN_TYPE:
|
||||||
|
|
|
@ -61,6 +61,8 @@ import jakarta.ws.rs.client.WebTarget;
|
||||||
import jakarta.ws.rs.core.Form;
|
import jakarta.ws.rs.core.Form;
|
||||||
import jakarta.ws.rs.core.HttpHeaders;
|
import jakarta.ws.rs.core.HttpHeaders;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
@ -128,6 +130,16 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
RoleModel impersonateRole = management.getRealmManagementClient().getRole(ImpersonationConstants.IMPERSONATION_ROLE);
|
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");
|
ClientModel clientExchanger = realm.addClient("client-exchanger");
|
||||||
clientExchanger.setClientId("client-exchanger");
|
clientExchanger.setClientId("client-exchanger");
|
||||||
clientExchanger.setPublicClient(false);
|
clientExchanger.setPublicClient(false);
|
||||||
|
@ -139,6 +151,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
|
||||||
clientExchanger.addScopeMapping(impersonateRole);
|
clientExchanger.addScopeMapping(impersonateRole);
|
||||||
clientExchanger.addProtocolMapper(UserSessionNoteMapper.createUserSessionNoteMapper(IMPERSONATOR_ID));
|
clientExchanger.addProtocolMapper(UserSessionNoteMapper.createUserSessionNoteMapper(IMPERSONATOR_ID));
|
||||||
clientExchanger.addProtocolMapper(UserSessionNoteMapper.createUserSessionNoteMapper(IMPERSONATOR_USERNAME));
|
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");
|
ClientModel illegal = realm.addClient("illegal");
|
||||||
illegal.setClientId("illegal");
|
illegal.setClientId("illegal");
|
||||||
|
@ -223,6 +236,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
|
||||||
clientRep.addClient(directLegal.getId());
|
clientRep.addClient(directLegal.getId());
|
||||||
clientRep.addClient(noRefreshToken.getId());
|
clientRep.addClient(noRefreshToken.getId());
|
||||||
clientRep.addClient(serviceAccount.getId());
|
clientRep.addClient(serviceAccount.getId());
|
||||||
|
clientRep.addClient(differentScopeClient.getId());
|
||||||
|
|
||||||
ResourceServer server = management.realmResourceServer();
|
ResourceServer server = management.realmResourceServer();
|
||||||
Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep);
|
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
|
@Test
|
||||||
@UncaughtServerErrorExpected
|
@UncaughtServerErrorExpected
|
||||||
public void testExchangeFromPublicClient() throws Exception {
|
public void testExchangeFromPublicClient() throws Exception {
|
||||||
|
@ -413,7 +513,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
|
||||||
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
|
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
|
||||||
AccessToken exchangedToken = verifier.parse().getToken();
|
AccessToken exchangedToken = verifier.parse().getToken();
|
||||||
Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor());
|
Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor());
|
||||||
Assert.assertNull(exchangedToken.getAudience());
|
assertNotNull(exchangedToken.getAudience());
|
||||||
Assert.assertEquals("impersonated-user", exchangedToken.getPreferredUsername());
|
Assert.assertEquals("impersonated-user", exchangedToken.getPreferredUsername());
|
||||||
Assert.assertNull(exchangedToken.getRealmAccess());
|
Assert.assertNull(exchangedToken.getRealmAccess());
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue