Avoid clients exchanging tokens using tokens issued to other clients (#11542)
This commit is contained in:
parent
ac79fd0c23
commit
76d83f46fa
6 changed files with 416 additions and 60 deletions
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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");
|
||||
|
|
Loading…
Reference in a new issue