diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index d3da2f48ee7..22121a16425 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -74,7 +74,7 @@ public class Profile { SCRIPTS("Write custom authenticators using JavaScript", Type.PREVIEW), TOKEN_EXCHANGE("Token Exchange Service", Type.PREVIEW, 1), - TOKEN_EXCHANGE_STANDARD_V2("Standard Token Exchange version 2", Type.EXPERIMENTAL, 2, Feature.ADMIN_FINE_GRAINED_AUTHZ), // TODO: Switch v2 token exchanges to depend admin-fine-grained-authz-v2 + TOKEN_EXCHANGE_STANDARD_V2("Standard Token Exchange version 2", Type.EXPERIMENTAL, 2), TOKEN_EXCHANGE_FEDERATED_V2("Federated Token Exchange for external-internal and internal-external token exchange", Type.EXPERIMENTAL, 2, Feature.ADMIN_FINE_GRAINED_AUTHZ), TOKEN_EXCHANGE_SUBJECT_IMPERSONATION_V2("Subject impersonation Token Exchange", Type.EXPERIMENTAL, 2, Feature.ADMIN_FINE_GRAINED_AUTHZ), diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java index 4188fd9449e..5e7aeaba360 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java @@ -206,8 +206,7 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv } - protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel targetUserSession, - AccessToken token, boolean disallowOnHolderOfTokenMismatch) { + protected String getRequestedTokenType() { String requestedTokenType = formParams.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE); if (requestedTokenType == null) { requestedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE; @@ -217,13 +216,13 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv event.detail(Details.REASON, "requested_token_type unsupported"); event.error(Errors.INVALID_REQUEST); throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST); - } + return requestedTokenType; + } + protected List getTargetAudienceClients() { List audienceParams = params.getAudience(); - ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor()); List targetAudienceClients = new ArrayList<>(); - if (audienceParams != null) { for (String audience : audienceParams) { ClientModel targetClient = realm.getClientByClientId(audience); @@ -237,12 +236,10 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv } } } - // Assume client itself is audience in case audience parameter not provided if (targetAudienceClients.isEmpty()) { targetAudienceClients.add(client); } - for (ClientModel targetClient : targetAudienceClients) { if (targetClient.isConsentRequired()) { event.detail(Details.REASON, "audience requires consent"); @@ -257,7 +254,11 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client disabled", Response.Status.BAD_REQUEST); } } + return targetAudienceClients; + } + protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTokenMismatch, List targetAudienceClients) { + ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor()); for (ClientModel targetClient : targetAudienceClients) { boolean isClientTheAudience = targetClient.equals(client); if (isClientTheAudience) { @@ -281,7 +282,14 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv } } } + } + protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel targetUserSession, + AccessToken token, boolean disallowOnHolderOfTokenMismatch) { + + String requestedTokenType = getRequestedTokenType(); + List targetAudienceClients = getTargetAudienceClients(); + validateAudience(token, disallowOnHolderOfTokenMismatch, targetAudienceClients); String scope = getRequestedScope(token, targetAudienceClients); try { @@ -300,7 +308,7 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST); } - private void forbiddenIfClientIsNotWithinTokenAudience(AccessToken token, ClientModel tokenHolder) { + protected void forbiddenIfClientIsNotWithinTokenAudience(AccessToken token, ClientModel tokenHolder) { if (token != null && !token.hasAudience(client.getClientId())) { event.detail(Details.REASON, "client is not within the token audience"); event.error(Errors.NOT_ALLOWED); @@ -308,7 +316,7 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv } } - private void forbiddenIfClientIsNotTokenHolder(boolean disallowOnHolderOfTokenMismatch, ClientModel tokenHolder) { + protected void forbiddenIfClientIsNotTokenHolder(boolean disallowOnHolderOfTokenMismatch, ClientModel tokenHolder) { if (disallowOnHolderOfTokenMismatch && !client.equals(tokenHolder)) { event.detail(Details.REASON, "client is not the token holder"); event.error(Errors.NOT_ALLOWED); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java index 21519eb4965..1bb3c179b10 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java @@ -121,6 +121,30 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider return exchangeClientToClient(tokenUser, tokenSession, token, true); } + protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTokenMismatch, List targetAudienceClients) { + ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor()); + //reject if the requester-client is not in the audience of the subject token + if (!client.equals(tokenHolder)) { + forbiddenIfClientIsNotWithinTokenAudience(token, null); + } + for (ClientModel targetClient : targetAudienceClients) { + boolean isClientTheAudience = targetClient.equals(client); + if (isClientTheAudience) { + if (client.isPublicClient()) { + // public clients can only exchange on to themselves if they are the token holder + forbiddenIfClientIsNotTokenHolder(disallowOnHolderOfTokenMismatch, tokenHolder); + } else if (!client.equals(tokenHolder)) { + // confidential clients can only exchange to themselves if they are within the token audience + forbiddenIfClientIsNotWithinTokenAudience(token, tokenHolder); + } + } else { + if (client.isPublicClient()) { + // public clients can not exchange tokens from other client + forbiddenIfClientIsNotTokenHolder(disallowOnHolderOfTokenMismatch, tokenHolder); + } + } + } + } // For now, include "scope" parameter as is @Override @@ -128,13 +152,11 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider return params.getScope(); } - protected void setClientToContext(List targetAudienceClients) { // The client requesting exchange is set in the context session.getContext().setClient(client); } - protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, List targetAudienceClients, String scope) { RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/AbstractStandardTokenExchangeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/AbstractStandardTokenExchangeTest.java index 985bb5a7dc4..b5534eac681 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/AbstractStandardTokenExchangeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/AbstractStandardTokenExchangeTest.java @@ -95,7 +95,7 @@ public abstract class AbstractStandardTokenExchangeTest extends AbstractKeycloak return accessToken; } - private String getSessionIdFromToken(String accessToken) throws Exception { + protected String getSessionIdFromToken(String accessToken) throws Exception { return TokenVerifier.create(accessToken, AccessToken.class) .parse() .getToken() diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/ClientTokenExchangeAudienceAndScopesTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/ClientTokenExchangeAudienceAndScopesTest.java index c3dae48af61..400799ea2f8 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/ClientTokenExchangeAudienceAndScopesTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/ClientTokenExchangeAudienceAndScopesTest.java @@ -48,7 +48,6 @@ import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; * @author Marek Posolda */ @EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE_STANDARD_V2, skipRestart = true) -@EnableFeature(value = Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, skipRestart = true) // TODO: Remove as we may not need to use FGAP for token exchange @FixMethodOrder(MethodSorters.NAME_ASCENDING) public class ClientTokenExchangeAudienceAndScopesTest extends AbstractKeycloakTest { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java index 8dac07615c9..266993428a5 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java @@ -19,10 +19,9 @@ package org.keycloak.testsuite.oauth.tokenexchange; -import java.util.Map; - import org.junit.Ignore; import org.junit.Test; +import org.keycloak.OAuth2Constants; import org.keycloak.TokenVerifier; import org.keycloak.common.Profile; import org.keycloak.models.ClientModel; @@ -40,13 +39,13 @@ import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected; import org.keycloak.testsuite.util.oauth.AccessTokenResponse; import org.keycloak.testsuite.util.oauth.OAuthClient; +import static org.junit.Assert.assertTrue; import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; /** * @author Marek Posolda */ @EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE_STANDARD_V2, skipRestart = true) -@EnableFeature(value = Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, skipRestart = true) // TODO: Replace with admin-fine-grained-authz V2 public class StandardTokenExchangeV2Test extends AbstractStandardTokenExchangeTest { @Override @@ -86,6 +85,43 @@ public class StandardTokenExchangeV2Test extends AbstractStandardTokenExchangeTe return accessToken; } + @Test + @UncaughtServerErrorExpected + public void testExchange() throws Exception { + setupRealm(); + oauth.realm(TEST); + String accessToken = getInitialAccessTokenForClientExchanger(); + { + OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret"); + Assert.assertEquals(OAuth2Constants.REFRESH_TOKEN_TYPE, response.getIssuedTokenType()); + String exchangedTokenString = response.getAccessToken(); + TokenVerifier verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class); + AccessToken exchangedToken = verifier.parse().getToken(); + Assert.assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId()); + Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor()); + Assert.assertEquals("target", exchangedToken.getAudience()[0]); + Assert.assertEquals(exchangedToken.getPreferredUsername(), "user"); + assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); + } + { + OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, accessToken, "target", "legal", "secret"); + Assert.assertEquals(OAuth2Constants.REFRESH_TOKEN_TYPE, response.getIssuedTokenType()); + String exchangedTokenString = response.getAccessToken(); + TokenVerifier verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class); + AccessToken exchangedToken = verifier.parse().getToken(); + Assert.assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId()); + Assert.assertEquals("legal", exchangedToken.getIssuedFor()); + Assert.assertEquals("target", exchangedToken.getAudience()[0]); + Assert.assertEquals(exchangedToken.getPreferredUsername(), "user"); + assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); + } + { + //exchange not allowed due the illegal client is not in the client-exchanger audience + OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, accessToken, "target", "illegal", "secret"); + Assert.assertEquals(403, response.getStatusCode()); + } + } + // Scope parameter is different with V2. TODO: Should write differently this test for V2 @Test @UncaughtServerErrorExpected @@ -102,4 +138,19 @@ public class StandardTokenExchangeV2Test extends AbstractStandardTokenExchangeTe } + //token exchange for public client will be not supported for v2 TODO: Should write differently this test for V2 + @Test + @Ignore + @UncaughtServerErrorExpected + public void testExchangeFromPublicClient() { + } + + + //token exchange for public client will be not supported for v2 TODO: Should write differently this test for V2 + @Test + @Ignore + @UncaughtServerErrorExpected + public void testPublicClientNotAllowed() { + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/TokenExchangeTestUtils.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/TokenExchangeTestUtils.java index dabe27abbae..9a5ac0f3136 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/TokenExchangeTestUtils.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/TokenExchangeTestUtils.java @@ -21,6 +21,7 @@ package org.keycloak.testsuite.oauth.tokenexchange; import org.keycloak.authorization.model.Policy; import org.keycloak.authorization.model.ResourceServer; +import org.keycloak.common.Profile; import org.keycloak.models.ClientModel; import org.keycloak.models.ImpersonationConstants; import org.keycloak.models.KeycloakSession; @@ -60,12 +61,9 @@ public class TokenExchangeTestUtils { RealmModel realm = session.realms().getRealmByName(TEST); RoleModel exampleRole = realm.getRole("example"); - AdminPermissionManagement management = AdminPermissions.management(session, realm); ClientModel target = realm.getClientByClientId("target"); assertNotNull(target); - RoleModel impersonateRole = management.getRealmPermissionsClient().getRole(ImpersonationConstants.IMPERSONATION_ROLE); - ClientModel differentScopeClient = realm.addClient("different-scope-client"); differentScopeClient.setClientId("different-scope-client"); differentScopeClient.setPublicClient(false); @@ -84,10 +82,18 @@ public class TokenExchangeTestUtils { clientExchanger.setSecret("secret"); clientExchanger.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); clientExchanger.setFullScopeAllowed(false); - clientExchanger.addScopeMapping(impersonateRole); + + if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) { + AdminPermissionManagement management = AdminPermissions.management(session, realm); + RoleModel impersonateRole = management.getRealmPermissionsClient().getRole(ImpersonationConstants.IMPERSONATION_ROLE); + 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)); + clientExchanger.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("allowed-exchanger1", null, "legal", true, false, true)); + clientExchanger.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("allowed-exchanger2", null, "no-refresh-token", true, false, true)); ClientModel illegal = realm.addClient("illegal"); illegal.setClientId("illegal"); @@ -176,9 +182,12 @@ public class TokenExchangeTestUtils { clientRep.addClient(serviceAccount.getId()); clientRep.addClient(differentScopeClient.getId()); - ResourceServer server = management.realmResourceServer(); - Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep); - management.clients().exchangeToPermission(target).addAssociatedPolicy(clientPolicy); + if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) { + AdminPermissionManagement management = AdminPermissions.management(session, realm); + ResourceServer server = management.realmResourceServer(); + Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep); + management.clients().exchangeToPermission(target).addAssociatedPolicy(clientPolicy); + } // permission for user impersonation for a client @@ -188,17 +197,26 @@ public class TokenExchangeTestUtils { clientImpersonateRep.addClient(directPublic.getId()); clientImpersonateRep.addClient(directUntrustedPublic.getId()); clientImpersonateRep.addClient(directNoSecret.getId()); - server = management.realmResourceServer(); - Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep); - management.users().setPermissionsEnabled(true); - management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy); - management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE); + + if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) { + AdminPermissionManagement management = AdminPermissions.management(session, realm); + ResourceServer server = management.realmResourceServer(); + Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep); + management.users().setPermissionsEnabled(true); + management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy); + management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE); + } UserModel user = session.users().addUser(realm, "user"); user.setEnabled(true); user.credentialManager().updateCredential(UserCredentialModel.password("password")); user.grantRole(exampleRole); - user.grantRole(impersonateRole); + + if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) { + AdminPermissionManagement management = AdminPermissions.management(session, realm); + RoleModel impersonateRole = management.getRealmPermissionsClient().getRole(ImpersonationConstants.IMPERSONATION_ROLE); + user.grantRole(impersonateRole); + } UserModel bad = session.users().addUser(realm, "bad-impersonator"); bad.setEnabled(true); @@ -221,7 +239,7 @@ public class TokenExchangeTestUtils { public static void addDirectExchanger(KeycloakSession session) { RealmModel realm = session.realms().getRealmByName(TEST); RoleModel exampleRole = realm.addRole("example"); - AdminPermissionManagement management = AdminPermissions.management(session, realm); + ClientModel target = realm.addClient("target"); target.setName("target"); @@ -243,18 +261,19 @@ public class TokenExchangeTestUtils { directExchanger.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); directExchanger.setFullScopeAllowed(false); - // permission for client to client exchange to "target" client - management.clients().setPermissionsEnabled(target, true); - ClientPolicyRepresentation clientImpersonateRep = new ClientPolicyRepresentation(); clientImpersonateRep.setName("clientImpersonatorsDirect"); clientImpersonateRep.addClient(directExchanger.getId()); - ResourceServer server = management.realmResourceServer(); - Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep); - management.users().setPermissionsEnabled(true); - management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy); - management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE); + if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) { + AdminPermissionManagement management = AdminPermissions.management(session, realm); + management.clients().setPermissionsEnabled(target, true); + ResourceServer server = management.realmResourceServer(); + Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep); + management.users().setPermissionsEnabled(true); + management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy); + management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE); + } UserModel impersonatedUser = session.users().addUser(realm, "impersonated-user"); impersonatedUser.setEnabled(true);