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

This commit is contained in:
Pedro Igor 2022-04-20 14:14:55 -03:00 committed by GitHub
parent ac79fd0c23
commit 76d83f46fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 416 additions and 60 deletions

View file

@ -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) {

View file

@ -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<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;
}
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);
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<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;
}
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);
}

View file

@ -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);

View file

@ -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<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

View file

@ -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<String, String> entry : additionalParams.entrySet()) {

View file

@ -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<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 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<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 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<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");