VYPR
Critical severityNVD Advisory· Published Jul 7, 2022· Updated Aug 2, 2024

CVE-2022-1245

CVE-2022-1245

Description

A privilege escalation flaw was found in the token exchange feature of keycloak. Missing authorization allows a client application holding a valid access token to exchange tokens for any target client by passing the client_id of the target. This could allow a client to gain unauthorized access to additional services.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
org.keycloak:keycloak-servicesMaven
< 18.0.018.0.0

Affected products

1

Patches

1
76d83f46fad9

Avoid clients exchanging tokens using tokens issued to other clients (#11542)

https://github.com/keycloak/keycloakPedro IgorApr 20, 2022via ghsa
6 files changed · +412 56
  • services/src/main/java/org/keycloak/protocol/oidc/DefaultTokenExchangeProvider.java+50 10 modified
    @@ -178,6 +178,8 @@ protected Response tokenExchange() {
             }
     
             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 @@ protected Response tokenExchange() {
                     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 @@ protected Response tokenExchange() {
                         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 @@ protected Response tokenExchange() {
     
             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 @@ protected Response exchangeToIdentityProvider(UserModel targetUser, UserSessionM
     
         }
     
    -    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 @@ protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel
                 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 @@ protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel
                 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 @@ protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel
             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 @@ protected Response exchangeExternalToken(String issuer, String subjectToken) {
             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) {
    
  • services/src/main/java/org/keycloak/services/resources/admin/permissions/ClientPermissions.java+36 39 modified
    @@ -306,50 +306,47 @@ public Map<String, String> getPermissions(ClientModel client) {
         @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;
    -            }
    +        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;
    -            }
    +        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;
    -            }
    +        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<Policy> 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;
    -            }
    +        Set<Policy> 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;
    +        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<String, Collection<String>> getBaseAttributes() {
    +                Map<String, Collection<String>> attributes = super.getBaseAttributes();
    +                attributes.put("kc.client.id", Arrays.asList(authorizedClient.getClientId()));
    +                return attributes;
                 }
    -            ClientModelIdentity identity = new ClientModelIdentity(session, authorizedClient);
    -            EvaluationContext context = new DefaultEvaluationContext(identity, session) {
    -                @Override
    -                public Map<String, Collection<String>> getBaseAttributes() {
    -                    Map<String, Collection<String>> attributes = super.getBaseAttributes();
    -                    attributes.put("kc.client.id", Arrays.asList(authorizedClient.getClientId()));
    -                    return attributes;
    -                }
    -
    -            };
    -            return root.evaluatePermission(resource, server, context, scope);
    -        }
    -        return true;
    +
    +        };
    +        return root.evaluatePermission(resource, server, context, scope);
         }
     
     
    
  • services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissionEvaluator.java+3 2 modified
    @@ -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<String, Boolean> getAccess(UserModel user);
     
    
  • services/src/main/java/org/keycloak/services/resources/admin/permissions/UserPermissions.java+22 4 modified
    @@ -41,6 +41,7 @@
     
     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 @@ public Map<String, Collection<String>> getBaseAttributes() {
         }
     
         @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 @@ public boolean isImpersonatable(UserModel user) {
                 return true;
             }
     
    -        return hasPermission(new DefaultEvaluationContext(new UserModelIdentity(root.realm, user), session), USER_IMPERSONATED_SCOPE);
    +        Map<String, List<String>> 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
    
  • testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java+4 1 modified
    @@ -655,7 +655,10 @@ public AccessTokenResponse doTokenExchange(String realm, String token, String ta
                 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<String, String> entry : additionalParams.entrySet()) {
    
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java+297 0 modified
    @@ -35,10 +35,12 @@
     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.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 static void setupRealm(KeycloakSession session) {
             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 static void setupRealm(KeycloakSession session) {
             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 static void setupRealm(KeycloakSession session) {
             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 void testExchange() throws Exception {
             }
         }
     
    +    @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<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, "target", "client-exchanger", "secret");
    +        String exchangedTokenString = response.getAccessToken();
    +        TokenVerifier<AccessToken> 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 void testImpersonation() throws Exception {
                 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<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"));
    +
    +        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<AccessToken> 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<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"));
    +
    +        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 void testDirectImpersonation() throws Exception {
             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 void testExchangeNoRefreshToken() throws Exception {
             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<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, "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<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, "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<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"));
    +
    +        // 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");
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.