From 76d83f46fad94ebcbedaa49e6daad458e2894e52 Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Wed, 20 Apr 2022 14:14:55 -0300 Subject: [PATCH] Avoid clients exchanging tokens using tokens issued to other clients (#11542) --- .../oidc/DefaultTokenExchangeProvider.java | 60 +++- .../admin/permissions/ClientPermissions.java | 83 +++-- .../permissions/UserPermissionEvaluator.java | 5 +- .../admin/permissions/UserPermissions.java | 26 +- .../keycloak/testsuite/util/OAuthClient.java | 5 +- .../oauth/ClientTokenExchangeTest.java | 297 ++++++++++++++++++ 6 files changed, 416 insertions(+), 60 deletions(-) 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 b9bf6c1e88..9c16f715e7 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java @@ -178,6 +178,8 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { } String requestedSubject = formParams.getFirst(OAuth2Constants.REQUESTED_SUBJECT); + boolean disallowOnHolderOfTokenMismatch = true; + if (requestedSubject != null) { event.detail(Details.REQUESTED_SUBJECT, requestedSubject); UserModel requestedUser = session.users().getUserByUsername(realm, requestedSubject); @@ -197,12 +199,11 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { event.detail(Details.IMPERSONATOR, tokenUser.getUsername()); // for this case, the user represented by the token, must have permission to impersonate. AdminAuth auth = new AdminAuth(realm, token, tokenUser, client); - if (!AdminPermissions.evaluator(session, realm, auth).users().canImpersonate(requestedUser)) { + if (!AdminPermissions.evaluator(session, realm, auth).users().canImpersonate(requestedUser, client)) { event.detail(Details.REASON, "subject not allowed to impersonate"); event.error(Errors.NOT_ALLOWED); throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); } - } else { // no token is being exchanged, this is a direct exchange. Client must be authenticated, not public, and must be allowed // to impersonate @@ -217,6 +218,9 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { event.error(Errors.NOT_ALLOWED); throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); } + + // see https://issues.redhat.com/browse/KEYCLOAK-5492 + disallowOnHolderOfTokenMismatch = false; } tokenSession = session.sessions().createUserSession(realm, requestedUser, requestedUser.getUsername(), clientConnection.getRemoteAddr(), "impersonate", false, null, null); @@ -230,7 +234,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { String requestedIssuer = formParams.getFirst(OAuth2Constants.REQUESTED_ISSUER); if (requestedIssuer == null) { - return exchangeClientToClient(tokenUser, tokenSession); + return exchangeClientToClient(tokenUser, tokenSession, token, disallowOnHolderOfTokenMismatch); } else { try { return exchangeToIdentityProvider(tokenUser, tokenSession, requestedIssuer); @@ -271,7 +275,8 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { } - protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel targetUserSession) { + protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel targetUserSession, + AccessToken token, boolean disallowOnHolderOfTokenMismatch) { String requestedTokenType = formParams.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE); if (requestedTokenType == null) { requestedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE; @@ -283,8 +288,11 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST); } - ClientModel targetClient = client; + String audience = formParams.getFirst(OAuth2Constants.AUDIENCE); + ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor()); + ClientModel targetClient = client; + if (audience != null) { targetClient = realm.getClientByClientId(audience); if (targetClient == null) { @@ -301,10 +309,26 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client requires user consent", Response.Status.BAD_REQUEST); } - if (!targetClient.equals(client) && !AdminPermissions.management(session, realm).clients().canExchangeTo(client, targetClient)) { - event.detail(Details.REASON, "client not allowed to exchange to audience"); - event.error(Errors.NOT_ALLOWED); - throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + boolean isClientTheAudience = client.equals(targetClient); + + 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); + } + if (!AdminPermissions.management(session, realm).clients().canExchangeTo(client, targetClient)) { + event.detail(Details.REASON, "client not allowed to exchange to audience"); + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN); + } } String scope = formParams.getFirst(OAuth2Constants.SCOPE); @@ -320,6 +344,22 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST); } + private 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); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client is not within the token audience", Response.Status.FORBIDDEN); + } + } + + private 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); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client is not the holder of the token", Response.Status.FORBIDDEN); + } + } + protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType, ClientModel targetClient, String audience, String scope) { RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false); @@ -457,7 +497,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider { userSession.setNote(IdentityProvider.EXTERNAL_IDENTITY_PROVIDER, externalIdpModel.get().getAlias()); userSession.setNote(IdentityProvider.FEDERATED_ACCESS_TOKEN, subjectToken); - return exchangeClientToClient(user, userSession); + return exchangeClientToClient(user, userSession, null, false); } protected UserModel importUserFromExternalIdentity(BrokeredIdentityContext context) { diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissions.java index a456a5ce59..48470eab08 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissions.java @@ -306,50 +306,47 @@ class ClientPermissions implements ClientPermissionEvaluator, ClientPermissionM @Override public boolean canExchangeTo(ClientModel authorizedClient, ClientModel to) { - if (!authorizedClient.equals(to)) { - ResourceServer server = resourceServer(to); - if (server == null) { - logger.debug("No resource server set up for target client"); - return false; - } - - Resource resource = authz.getStoreFactory().getResourceStore().findByName(server, getResourceName(to)); - if (resource == null) { - logger.debug("No resource object set up for target client"); - return false; - } - - Policy policy = authz.getStoreFactory().getPolicyStore().findByName(server, getExchangeToPermissionName(to)); - if (policy == null) { - logger.debug("No permission object set up for target client"); - return false; - } - - Set associatedPolicies = policy.getAssociatedPolicies(); - // if no policies attached to permission then just do default behavior - if (associatedPolicies == null || associatedPolicies.isEmpty()) { - logger.debug("No policies set up for permission on target client"); - return false; - } - - Scope scope = exchangeToScope(server); - if (scope == null) { - logger.debug(TOKEN_EXCHANGE + " not initialized"); - return false; - } - ClientModelIdentity identity = new ClientModelIdentity(session, authorizedClient); - EvaluationContext context = new DefaultEvaluationContext(identity, session) { - @Override - public Map> getBaseAttributes() { - Map> attributes = super.getBaseAttributes(); - attributes.put("kc.client.id", Arrays.asList(authorizedClient.getClientId())); - return attributes; - } - - }; - return root.evaluatePermission(resource, server, context, scope); + ResourceServer server = resourceServer(to); + if (server == null) { + logger.debug("No resource server set up for target client"); + return false; } - return true; + + Resource resource = authz.getStoreFactory().getResourceStore().findByName(server, getResourceName(to)); + if (resource == null) { + logger.debug("No resource object set up for target client"); + return false; + } + + Policy policy = authz.getStoreFactory().getPolicyStore().findByName(server, getExchangeToPermissionName(to)); + if (policy == null) { + logger.debug("No permission object set up for target client"); + return false; + } + + Set associatedPolicies = policy.getAssociatedPolicies(); + // if no policies attached to permission then just do default behavior + if (associatedPolicies == null || associatedPolicies.isEmpty()) { + logger.debug("No policies set up for permission on target client"); + return false; + } + + Scope scope = exchangeToScope(server); + if (scope == null) { + logger.debug(TOKEN_EXCHANGE + " not initialized"); + return false; + } + ClientModelIdentity identity = new ClientModelIdentity(session, authorizedClient); + EvaluationContext context = new DefaultEvaluationContext(identity, session) { + @Override + public Map> getBaseAttributes() { + Map> attributes = super.getBaseAttributes(); + attributes.put("kc.client.id", Arrays.asList(authorizedClient.getClientId())); + return attributes; + } + + }; + return root.evaluatePermission(resource, server, context, scope); } diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionEvaluator.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionEvaluator.java index c9a7e48f42..35e06caf3c 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionEvaluator.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionEvaluator.java @@ -16,6 +16,7 @@ */ package org.keycloak.services.resources.admin.permissions; +import org.keycloak.models.ClientModel; import org.keycloak.models.UserModel; import java.util.Map; @@ -40,8 +41,8 @@ public interface UserPermissionEvaluator { void requireImpersonate(UserModel user); boolean canImpersonate(); - boolean canImpersonate(UserModel user); - boolean isImpersonatable(UserModel user); + boolean canImpersonate(UserModel user, ClientModel requester); + boolean isImpersonatable(UserModel user, ClientModel requester); Map getAccess(UserModel user); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissions.java b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissions.java index afdf0bee2d..f7249b39b7 100644 --- a/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissions.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissions.java @@ -41,6 +41,7 @@ import org.keycloak.services.ForbiddenException; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -355,16 +356,20 @@ class UserPermissions implements UserPermissionEvaluator, UserPermissionManageme } @Override - public boolean canImpersonate(UserModel user) { + public boolean canImpersonate(UserModel user, ClientModel requester) { if (!canImpersonate()) { return false; } - return isImpersonatable(user); + return isImpersonatable(user, requester); + } + + private boolean canImpersonate(UserModel user) { + return canImpersonate(user, null); } @Override - public boolean isImpersonatable(UserModel user) { + public boolean isImpersonatable(UserModel user, ClientModel requester) { ResourceServer server = root.realmResourceServer(); if (server == null) { @@ -389,7 +394,20 @@ class UserPermissions implements UserPermissionEvaluator, UserPermissionManageme return true; } - return hasPermission(new DefaultEvaluationContext(new UserModelIdentity(root.realm, user), session), USER_IMPERSONATED_SCOPE); + Map> additionalClaims = Collections.emptyMap(); + + if (requester != null) { + // make sure the requesting client id is available from the context as we are using a user identity that does not rely on token claims + additionalClaims = new HashMap<>(); + additionalClaims.put("kc.client.id", Arrays.asList(requester.getClientId())); + } + + return hasPermission(new DefaultEvaluationContext(new UserModelIdentity(root.realm, user), additionalClaims, session), USER_IMPERSONATED_SCOPE); + } + + @Override + public boolean isImpersonatable(UserModel user) { + return isImpersonatable(user, null); } @Override diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 359d30bc41..813ede0aaa 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -655,7 +655,10 @@ public class OAuthClient { parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)); parameters.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN, token)); parameters.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)); - parameters.add(new BasicNameValuePair(OAuth2Constants.AUDIENCE, targetAudience)); + + if (targetAudience != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.AUDIENCE, targetAudience)); + } if (additionalParams != null) { for (Map.Entry entry : additionalParams.entrySet()) { 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 813b3e1613..cd07aa86e7 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 @@ -35,10 +35,12 @@ import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.AudienceProtocolMapper; import org.keycloak.protocol.oidc.mappers.UserSessionNoteMapper; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.OAuth2ErrorRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation; import org.keycloak.representations.idm.authorization.DecisionStrategy; @@ -66,6 +68,7 @@ import java.util.List; import java.util.Map; import static org.hamcrest.Matchers.instanceOf; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.keycloak.common.Profile.Feature.AUTHORIZATION; @@ -175,6 +178,18 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { directPublic.setEnabled(true); directPublic.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); directPublic.setFullScopeAllowed(false); + directPublic.addRedirectUri("https://localhost:8543/auth/realms/master/app/auth"); + directPublic.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("client-exchanger-audience", clientExchanger.getClientId(), null, true, false)); + + ClientModel directUntrustedPublic = realm.addClient("direct-public-untrusted"); + directUntrustedPublic.setClientId("direct-public-untrusted"); + directUntrustedPublic.setPublicClient(true); + directUntrustedPublic.setDirectAccessGrantsEnabled(true); + directUntrustedPublic.setEnabled(true); + directUntrustedPublic.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + directUntrustedPublic.setFullScopeAllowed(false); + directUntrustedPublic.addRedirectUri("https://localhost:8543/auth/realms/master/app/auth"); + directUntrustedPublic.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("client-exchanger-audience", clientExchanger.getClientId(), null, true, false)); ClientModel directNoSecret = realm.addClient("direct-no-secret"); directNoSecret.setClientId("direct-no-secret"); @@ -212,6 +227,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { clientImpersonateRep.setName("clientImpersonators"); clientImpersonateRep.addClient(directLegal.getId()); clientImpersonateRep.addClient(directPublic.getId()); + clientImpersonateRep.addClient(directUntrustedPublic.getId()); clientImpersonateRep.addClient(directNoSecret.getId()); server = management.realmResourceServer(); Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep); @@ -230,6 +246,18 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { session.userCredentialManager().updateCredential(realm, bad, UserCredentialModel.password("password")); } + public static void setUpUserImpersonatePermissions(KeycloakSession session) { + RealmModel realm = session.realms().getRealmByName(TEST); + AdminPermissionManagement management = AdminPermissions.management(session, realm); + ResourceServer server = management.realmResourceServer(); + Policy userImpersonationPermission = management.users().userImpersonatedPermission(); + ClientPolicyRepresentation clientsAllowedToImpersonateRep = new ClientPolicyRepresentation(); + clientsAllowedToImpersonateRep.setName("clientsAllowedToImpersonateRep"); + clientsAllowedToImpersonateRep.addClient("direct-public"); + Policy clientsAllowedToImpersonate = management.authz().getStoreFactory().getPolicyStore().create(server, clientsAllowedToImpersonateRep); + userImpersonationPermission.addAssociatedPolicy(clientsAllowedToImpersonate); + } + @Override protected boolean isImportAfterEachMethod() { return true; @@ -277,6 +305,43 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { } } + @Test + @UncaughtServerErrorExpected + public void testExchangeFromPublicClient() throws Exception { + testingClient.server().run(ClientTokenExchangeTest::setupRealm); + + oauth.realm(TEST); + oauth.clientId("direct-public"); + OAuthClient.AuthorizationEndpointResponse authzResponse = oauth.doLogin("user", "password"); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(authzResponse.getCode(), "secret"); + + 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, "target", "client-exchanger", "secret"); + String exchangedTokenString = response.getAccessToken(); + TokenVerifier verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class); + AccessToken exchangedToken = verifier.parse().getToken(); + Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor()); + Assert.assertEquals("target", exchangedToken.getAudience()[0]); + Assert.assertEquals(exchangedToken.getPreferredUsername(), "user"); + Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); + + // can exchange to itself because the client is within the audience of the token issued to the public client + response = oauth.doTokenExchange(TEST, accessToken, null, "client-exchanger", "secret"); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + + // can not exchange to itself because the client is not within the audience of the token issued to the public client + response = oauth.doTokenExchange(TEST, accessToken, null, "direct-legal", "secret"); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode()); + + response = oauth.doTokenExchange(TEST, accessToken, null, "direct-public", null); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + } + @Test @UncaughtServerErrorExpected public void testImpersonation() throws Exception { @@ -357,6 +422,157 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { Assert.assertEquals(exchangedToken.getPreferredUsername(), "impersonated-user"); Assert.assertTrue(exchangedToken.getRealmAccess().isUserInRole("example")); } + + try (Response response = exchangeUrl.request() + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.CLIENT_ID, "direct-public") + .param(OAuth2Constants.SUBJECT_TOKEN, accessToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user") + + ))) { + org.junit.Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + assertEquals("Client is not the holder of the token", + response.readEntity(OAuth2ErrorRepresentation.class).getErrorDescription()); + } + + try (Response response = exchangeUrl.request() + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.CLIENT_ID, "direct-public") + .param(OAuth2Constants.SUBJECT_TOKEN, accessToken) + .param(OAuth2Constants.AUDIENCE, "direct-public") + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user") + + ))) { + org.junit.Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + assertEquals("Client is not the holder of the token", + response.readEntity(OAuth2ErrorRepresentation.class).getErrorDescription()); + } + + try (Response response = exchangeUrl.request() + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.CLIENT_ID, "direct-public") + .param(OAuth2Constants.SUBJECT_TOKEN, accessToken) + .param(OAuth2Constants.AUDIENCE, "client-exchanger") + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user") + + ))) { + org.junit.Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + assertEquals("Client is not the holder of the token", + response.readEntity(OAuth2ErrorRepresentation.class).getErrorDescription()); + } + } + + @UncaughtServerErrorExpected + @Test + public void testImpersonationUsingPublicClient() throws Exception { + testingClient.server().run(ClientTokenExchangeTest::setupRealm); + + oauth.realm(TEST); + oauth.clientId("direct-public"); + + Client httpClient = AdminClientUtil.createResteasyClient(); + + OAuthClient.AuthorizationEndpointResponse authzResponse = oauth.doLogin("user", "password"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(authzResponse.getCode(), "secret"); + String accessToken = tokenResponse.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")); + + WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT) + .path("/realms") + .path(TEST) + .path("protocol/openid-connect/token"); + System.out.println("Exchange url: " + exchangeUrl.getUri().toString()); + + Response response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-public", null)) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, accessToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user") + + )); + org.junit.Assert.assertEquals(200, response.getStatus()); + AccessTokenResponse accessTokenResponse = response.readEntity(AccessTokenResponse.class); + response.close(); + + String exchangedTokenString = accessTokenResponse.getToken(); + TokenVerifier verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class); + AccessToken exchangedToken = verifier.parse().getToken(); + Assert.assertEquals("direct-public", exchangedToken.getIssuedFor()); + Assert.assertEquals("impersonated-user", exchangedToken.getPreferredUsername()); + Assert.assertNull(exchangedToken.getRealmAccess()); + + testingClient.server().run(ClientTokenExchangeTest::setUpUserImpersonatePermissions); + } + + @UncaughtServerErrorExpected + @Test + public void testImpersonationUsingTokenIssuedToUntrustedPublicClient() throws Exception { + testingClient.server().run(ClientTokenExchangeTest::setupRealm); + testingClient.server().run(ClientTokenExchangeTest::setUpUserImpersonatePermissions); + + oauth.realm(TEST); + oauth.clientId("direct-public-untrusted"); + + Client httpClient = AdminClientUtil.createResteasyClient(); + + OAuthClient.AuthorizationEndpointResponse authzResponse = oauth.doLogin("user", "password"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(authzResponse.getCode(), "secret"); + String accessToken = tokenResponse.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")); + + WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT) + .path("/realms") + .path(TEST) + .path("protocol/openid-connect/token"); + System.out.println("Exchange url: " + exchangeUrl.getUri().toString()); + + Response response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-public-untrusted", null)) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, accessToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user") + + )); + org.junit.Assert.assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + + oauth.idTokenHint(tokenResponse.getIdToken()).openLogout(); + oauth.clientId("direct-public"); + authzResponse = oauth.doLogin("user", "password"); + tokenResponse = oauth.doAccessTokenRequest(authzResponse.getCode(), "secret"); + accessToken = tokenResponse.getAccessToken(); + + response = exchangeUrl.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-public", null)) + .post(Entity.form( + new Form() + .param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE) + .param(OAuth2Constants.SUBJECT_TOKEN, accessToken) + .param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE) + .param(OAuth2Constants.REQUESTED_SUBJECT, "impersonated-user") + + )); + org.junit.Assert.assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); } @Test @@ -414,6 +630,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { System.out.println("Exchange url: " + exchangeUrl.getUri().toString()); // direct-exchanger can impersonate from token "user" to user "impersonated-user" + // see https://issues.redhat.com/browse/KEYCLOAK-5492 { Response response = exchangeUrl.request() .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("direct-exchanger", "secret")) @@ -526,6 +743,86 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest { client.update(clientRepresentation); } + @Test + public void testClientExchangeToItself() 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, "client-exchanger", "secret"); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + + response = oauth.doTokenExchange(TEST, accessToken, "client-exchanger", "client-exchanger", "secret"); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + } + + @Test + public void testClientExchange() throws Exception { + testingClient.server().run(ClientTokenExchangeTest::setupRealm); + + oauth.realm(TEST); + oauth.clientId("direct-legal"); + 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, "target", "direct-legal", "secret"); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + } + + @Test + public void testPublicClientNotAllowed() throws Exception { + testingClient.server().run(ClientTokenExchangeTest::setupRealm); + + oauth.realm(TEST); + oauth.clientId("direct-legal"); + 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")); + + // public client has no permission to exchange with the client direct-legal to which the token was issued for + // if not set, the audience is calculated based on the client to which the token was issued for + response = oauth.doTokenExchange(TEST, accessToken, null, "direct-public", null); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode()); + assertEquals("Client is not the holder of the token", response.getErrorDescription()); + + // public client has no permission to exchange + response = oauth.doTokenExchange(TEST, accessToken, "target", "direct-public", null); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode()); + assertEquals("Client is not the holder of the token", response.getErrorDescription()); + + response = oauth.doTokenExchange(TEST, accessToken, "direct-legal", "direct-public", null); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode()); + assertEquals("Client is not the holder of the token", response.getErrorDescription()); + + // public client can not exchange a token to itself if the token was issued to another client + response = oauth.doTokenExchange(TEST, accessToken, "direct-public", "direct-public", null); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode()); + assertEquals("Client is not the holder of the token", response.getErrorDescription()); + + // client with access to exchange + response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret"); + assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode()); + + // client must pass the audience because the client has no permission to exchange with the calculated audience (direct-legal) + response = oauth.doTokenExchange(TEST, accessToken, null, "client-exchanger", "secret"); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatusCode()); + assertEquals("Client is not within the token audience", response.getErrorDescription()); + } + private static void addDirectExchanger(KeycloakSession session) { RealmModel realm = session.realms().getRealmByName(TEST); RoleModel exampleRole = realm.addRole("example");