Remove FGAP from standard token exchange v2

Closes #37108

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2025-02-11 10:20:36 +01:00 committed by Bruno Oliveira da Silva
parent 1afcf515aa
commit f2d931ba44
7 changed files with 138 additions and 39 deletions

View File

@ -74,7 +74,7 @@ public class Profile {
SCRIPTS("Write custom authenticators using JavaScript", Type.PREVIEW),
TOKEN_EXCHANGE("Token Exchange Service", Type.PREVIEW, 1),
TOKEN_EXCHANGE_STANDARD_V2("Standard Token Exchange version 2", Type.EXPERIMENTAL, 2, Feature.ADMIN_FINE_GRAINED_AUTHZ), // TODO: Switch v2 token exchanges to depend admin-fine-grained-authz-v2
TOKEN_EXCHANGE_STANDARD_V2("Standard Token Exchange version 2", Type.EXPERIMENTAL, 2),
TOKEN_EXCHANGE_FEDERATED_V2("Federated Token Exchange for external-internal and internal-external token exchange", Type.EXPERIMENTAL, 2, Feature.ADMIN_FINE_GRAINED_AUTHZ),
TOKEN_EXCHANGE_SUBJECT_IMPERSONATION_V2("Subject impersonation Token Exchange", Type.EXPERIMENTAL, 2, Feature.ADMIN_FINE_GRAINED_AUTHZ),

View File

@ -206,8 +206,7 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
}
protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel targetUserSession,
AccessToken token, boolean disallowOnHolderOfTokenMismatch) {
protected String getRequestedTokenType() {
String requestedTokenType = formParams.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE);
if (requestedTokenType == null) {
requestedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE;
@ -217,13 +216,13 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
event.detail(Details.REASON, "requested_token_type unsupported");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST);
}
return requestedTokenType;
}
protected List<ClientModel> getTargetAudienceClients() {
List<String> audienceParams = params.getAudience();
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());
List<ClientModel> targetAudienceClients = new ArrayList<>();
if (audienceParams != null) {
for (String audience : audienceParams) {
ClientModel targetClient = realm.getClientByClientId(audience);
@ -237,12 +236,10 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
}
}
}
// Assume client itself is audience in case audience parameter not provided
if (targetAudienceClients.isEmpty()) {
targetAudienceClients.add(client);
}
for (ClientModel targetClient : targetAudienceClients) {
if (targetClient.isConsentRequired()) {
event.detail(Details.REASON, "audience requires consent");
@ -257,7 +254,11 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client disabled", Response.Status.BAD_REQUEST);
}
}
return targetAudienceClients;
}
protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTokenMismatch, List<ClientModel> targetAudienceClients) {
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());
for (ClientModel targetClient : targetAudienceClients) {
boolean isClientTheAudience = targetClient.equals(client);
if (isClientTheAudience) {
@ -281,7 +282,14 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
}
}
}
}
protected Response exchangeClientToClient(UserModel targetUser, UserSessionModel targetUserSession,
AccessToken token, boolean disallowOnHolderOfTokenMismatch) {
String requestedTokenType = getRequestedTokenType();
List<ClientModel> targetAudienceClients = getTargetAudienceClients();
validateAudience(token, disallowOnHolderOfTokenMismatch, targetAudienceClients);
String scope = getRequestedScope(token, targetAudienceClients);
try {
@ -300,7 +308,7 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST);
}
private void forbiddenIfClientIsNotWithinTokenAudience(AccessToken token, ClientModel tokenHolder) {
protected void forbiddenIfClientIsNotWithinTokenAudience(AccessToken token, ClientModel tokenHolder) {
if (token != null && !token.hasAudience(client.getClientId())) {
event.detail(Details.REASON, "client is not within the token audience");
event.error(Errors.NOT_ALLOWED);
@ -308,7 +316,7 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
}
}
private void forbiddenIfClientIsNotTokenHolder(boolean disallowOnHolderOfTokenMismatch, ClientModel tokenHolder) {
protected void forbiddenIfClientIsNotTokenHolder(boolean disallowOnHolderOfTokenMismatch, ClientModel tokenHolder) {
if (disallowOnHolderOfTokenMismatch && !client.equals(tokenHolder)) {
event.detail(Details.REASON, "client is not the token holder");
event.error(Errors.NOT_ALLOWED);

View File

@ -121,6 +121,30 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
return exchangeClientToClient(tokenUser, tokenSession, token, true);
}
protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTokenMismatch, List<ClientModel> targetAudienceClients) {
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());
//reject if the requester-client is not in the audience of the subject token
if (!client.equals(tokenHolder)) {
forbiddenIfClientIsNotWithinTokenAudience(token, null);
}
for (ClientModel targetClient : targetAudienceClients) {
boolean isClientTheAudience = targetClient.equals(client);
if (isClientTheAudience) {
if (client.isPublicClient()) {
// public clients can only exchange on to themselves if they are the token holder
forbiddenIfClientIsNotTokenHolder(disallowOnHolderOfTokenMismatch, tokenHolder);
} else if (!client.equals(tokenHolder)) {
// confidential clients can only exchange to themselves if they are within the token audience
forbiddenIfClientIsNotWithinTokenAudience(token, tokenHolder);
}
} else {
if (client.isPublicClient()) {
// public clients can not exchange tokens from other client
forbiddenIfClientIsNotTokenHolder(disallowOnHolderOfTokenMismatch, tokenHolder);
}
}
}
}
// For now, include "scope" parameter as is
@Override
@ -128,13 +152,11 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
return params.getScope();
}
protected void setClientToContext(List<ClientModel> targetAudienceClients) {
// The client requesting exchange is set in the context
session.getContext().setClient(client);
}
protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType,
List<ClientModel> targetAudienceClients, String scope) {
RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false);

View File

@ -95,7 +95,7 @@ public abstract class AbstractStandardTokenExchangeTest extends AbstractKeycloak
return accessToken;
}
private String getSessionIdFromToken(String accessToken) throws Exception {
protected String getSessionIdFromToken(String accessToken) throws Exception {
return TokenVerifier.create(accessToken, AccessToken.class)
.parse()
.getToken()

View File

@ -48,7 +48,6 @@ import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE_STANDARD_V2, skipRestart = true)
@EnableFeature(value = Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, skipRestart = true) // TODO: Remove as we may not need to use FGAP for token exchange
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class ClientTokenExchangeAudienceAndScopesTest extends AbstractKeycloakTest {

View File

@ -19,10 +19,9 @@
package org.keycloak.testsuite.oauth.tokenexchange;
import java.util.Map;
import org.junit.Ignore;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.common.Profile;
import org.keycloak.models.ClientModel;
@ -40,13 +39,13 @@ import org.keycloak.testsuite.arquillian.annotation.UncaughtServerErrorExpected;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@EnableFeature(value = Profile.Feature.TOKEN_EXCHANGE_STANDARD_V2, skipRestart = true)
@EnableFeature(value = Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, skipRestart = true) // TODO: Replace with admin-fine-grained-authz V2
public class StandardTokenExchangeV2Test extends AbstractStandardTokenExchangeTest {
@Override
@ -86,6 +85,43 @@ public class StandardTokenExchangeV2Test extends AbstractStandardTokenExchangeTe
return accessToken;
}
@Test
@UncaughtServerErrorExpected
public void testExchange() throws Exception {
setupRealm();
oauth.realm(TEST);
String accessToken = getInitialAccessTokenForClientExchanger();
{
OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, accessToken, "target", "client-exchanger", "secret");
Assert.assertEquals(OAuth2Constants.REFRESH_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
Assert.assertEquals("client-exchanger", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
}
{
OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, accessToken, "target", "legal", "secret");
Assert.assertEquals(OAuth2Constants.REFRESH_TOKEN_TYPE, response.getIssuedTokenType());
String exchangedTokenString = response.getAccessToken();
TokenVerifier<AccessToken> verifier = TokenVerifier.create(exchangedTokenString, AccessToken.class);
AccessToken exchangedToken = verifier.parse().getToken();
Assert.assertEquals(getSessionIdFromToken(accessToken), exchangedToken.getSessionId());
Assert.assertEquals("legal", exchangedToken.getIssuedFor());
Assert.assertEquals("target", exchangedToken.getAudience()[0]);
Assert.assertEquals(exchangedToken.getPreferredUsername(), "user");
assertTrue(exchangedToken.getRealmAccess().isUserInRole("example"));
}
{
//exchange not allowed due the illegal client is not in the client-exchanger audience
OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, accessToken, "target", "illegal", "secret");
Assert.assertEquals(403, response.getStatusCode());
}
}
// Scope parameter is different with V2. TODO: Should write differently this test for V2
@Test
@UncaughtServerErrorExpected
@ -102,4 +138,19 @@ public class StandardTokenExchangeV2Test extends AbstractStandardTokenExchangeTe
}
//token exchange for public client will be not supported for v2 TODO: Should write differently this test for V2
@Test
@Ignore
@UncaughtServerErrorExpected
public void testExchangeFromPublicClient() {
}
//token exchange for public client will be not supported for v2 TODO: Should write differently this test for V2
@Test
@Ignore
@UncaughtServerErrorExpected
public void testPublicClientNotAllowed() {
}
}

View File

@ -21,6 +21,7 @@ package org.keycloak.testsuite.oauth.tokenexchange;
import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.ResourceServer;
import org.keycloak.common.Profile;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ImpersonationConstants;
import org.keycloak.models.KeycloakSession;
@ -60,12 +61,9 @@ public class TokenExchangeTestUtils {
RealmModel realm = session.realms().getRealmByName(TEST);
RoleModel exampleRole = realm.getRole("example");
AdminPermissionManagement management = AdminPermissions.management(session, realm);
ClientModel target = realm.getClientByClientId("target");
assertNotNull(target);
RoleModel impersonateRole = management.getRealmPermissionsClient().getRole(ImpersonationConstants.IMPERSONATION_ROLE);
ClientModel differentScopeClient = realm.addClient("different-scope-client");
differentScopeClient.setClientId("different-scope-client");
differentScopeClient.setPublicClient(false);
@ -84,10 +82,18 @@ public class TokenExchangeTestUtils {
clientExchanger.setSecret("secret");
clientExchanger.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
clientExchanger.setFullScopeAllowed(false);
clientExchanger.addScopeMapping(impersonateRole);
if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) {
AdminPermissionManagement management = AdminPermissions.management(session, realm);
RoleModel impersonateRole = management.getRealmPermissionsClient().getRole(ImpersonationConstants.IMPERSONATION_ROLE);
clientExchanger.addScopeMapping(impersonateRole);
}
clientExchanger.addProtocolMapper(UserSessionNoteMapper.createUserSessionNoteMapper(IMPERSONATOR_ID));
clientExchanger.addProtocolMapper(UserSessionNoteMapper.createUserSessionNoteMapper(IMPERSONATOR_USERNAME));
clientExchanger.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("different-scope-client-audience", differentScopeClient.getClientId(), null, true, false, true));
clientExchanger.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("allowed-exchanger1", null, "legal", true, false, true));
clientExchanger.addProtocolMapper(AudienceProtocolMapper.createClaimMapper("allowed-exchanger2", null, "no-refresh-token", true, false, true));
ClientModel illegal = realm.addClient("illegal");
illegal.setClientId("illegal");
@ -176,9 +182,12 @@ public class TokenExchangeTestUtils {
clientRep.addClient(serviceAccount.getId());
clientRep.addClient(differentScopeClient.getId());
ResourceServer server = management.realmResourceServer();
Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep);
management.clients().exchangeToPermission(target).addAssociatedPolicy(clientPolicy);
if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) {
AdminPermissionManagement management = AdminPermissions.management(session, realm);
ResourceServer server = management.realmResourceServer();
Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep);
management.clients().exchangeToPermission(target).addAssociatedPolicy(clientPolicy);
}
// permission for user impersonation for a client
@ -188,17 +197,26 @@ public class TokenExchangeTestUtils {
clientImpersonateRep.addClient(directPublic.getId());
clientImpersonateRep.addClient(directUntrustedPublic.getId());
clientImpersonateRep.addClient(directNoSecret.getId());
server = management.realmResourceServer();
Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep);
management.users().setPermissionsEnabled(true);
management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy);
management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE);
if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) {
AdminPermissionManagement management = AdminPermissions.management(session, realm);
ResourceServer server = management.realmResourceServer();
Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep);
management.users().setPermissionsEnabled(true);
management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy);
management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE);
}
UserModel user = session.users().addUser(realm, "user");
user.setEnabled(true);
user.credentialManager().updateCredential(UserCredentialModel.password("password"));
user.grantRole(exampleRole);
user.grantRole(impersonateRole);
if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) {
AdminPermissionManagement management = AdminPermissions.management(session, realm);
RoleModel impersonateRole = management.getRealmPermissionsClient().getRole(ImpersonationConstants.IMPERSONATION_ROLE);
user.grantRole(impersonateRole);
}
UserModel bad = session.users().addUser(realm, "bad-impersonator");
bad.setEnabled(true);
@ -221,7 +239,7 @@ public class TokenExchangeTestUtils {
public static void addDirectExchanger(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(TEST);
RoleModel exampleRole = realm.addRole("example");
AdminPermissionManagement management = AdminPermissions.management(session, realm);
ClientModel target = realm.addClient("target");
target.setName("target");
@ -243,18 +261,19 @@ public class TokenExchangeTestUtils {
directExchanger.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
directExchanger.setFullScopeAllowed(false);
// permission for client to client exchange to "target" client
management.clients().setPermissionsEnabled(target, true);
ClientPolicyRepresentation clientImpersonateRep = new ClientPolicyRepresentation();
clientImpersonateRep.setName("clientImpersonatorsDirect");
clientImpersonateRep.addClient(directExchanger.getId());
ResourceServer server = management.realmResourceServer();
Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep);
management.users().setPermissionsEnabled(true);
management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy);
management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE);
if (Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ) || Profile.isFeatureEnabled(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ_V2)) {
AdminPermissionManagement management = AdminPermissions.management(session, realm);
management.clients().setPermissionsEnabled(target, true);
ResourceServer server = management.realmResourceServer();
Policy clientImpersonatePolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientImpersonateRep);
management.users().setPermissionsEnabled(true);
management.users().adminImpersonatingPermission().addAssociatedPolicy(clientImpersonatePolicy);
management.users().adminImpersonatingPermission().setDecisionStrategy(DecisionStrategy.AFFIRMATIVE);
}
UserModel impersonatedUser = session.users().addUser(realm, "impersonated-user");
impersonatedUser.setEnabled(true);