Remove FGAP:v1 from external-internal token exchange (#40938)

Closes #40855

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano
2025-07-11 17:42:47 +02:00
committed by GitHub
parent cf0b3c542a
commit 2f36276ff0
4 changed files with 83 additions and 36 deletions

View File

@@ -323,7 +323,7 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
}
UserModel user = null;
if (! context.getIdpConfig().isTransientUsers()) {
if (!context.getIdpConfig().isTransientUsers()) {
FederatedIdentityModel federatedIdentityModel = new FederatedIdentityModel(providerId, context.getId(),
context.getUsername(), context.getToken());
@@ -435,9 +435,9 @@ public abstract class AbstractTokenExchangeProvider implements TokenExchangeProv
}
}
record ExternalExchangeContext (ExchangeExternalToken provider, IdentityProviderModel idpModel) {};
protected record ExternalExchangeContext (ExchangeExternalToken provider, IdentityProviderModel idpModel) {};
private ExternalExchangeContext locateExchangeExternalTokenByAlias(String alias) {
protected ExternalExchangeContext locateExchangeExternalTokenByAlias(String alias) {
try {
IdentityProvider<?> idp = IdentityBrokerService.getIdentityProvider(session, alias);

View File

@@ -20,16 +20,27 @@
package org.keycloak.protocol.oidc.tokenexchange;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.TokenExchangeContext;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.UserSessionManager;
import java.util.Arrays;
import java.util.List;
/**
* Provider for external-internal token exchange
*
* TODO Should not extend from V1TokenExchangeProvider, but rather AbstractTokenExchangeProvider or from StandardTokenExchangeProvider (as issuing internal tokens might be done in a same/similar way like for standard V2 provider)
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class ExternalToInternalTokenExchangeProvider extends V1TokenExchangeProvider {
public class ExternalToInternalTokenExchangeProvider extends StandardTokenExchangeProvider {
@Override
public boolean supports(TokenExchangeContext context) {
@@ -49,4 +60,53 @@ public class ExternalToInternalTokenExchangeProvider extends V1TokenExchangeProv
return exchangeExternalToken(subjectIssuer, subjectToken);
}
@Override
protected List<String> getSupportedOAuthResponseTokenTypes() {
return Arrays.asList(OAuth2Constants.ACCESS_TOKEN_TYPE, OAuth2Constants.ID_TOKEN_TYPE);
}
@Override
protected String getRequestedTokenType() {
String requestedTokenType = params.getRequestedTokenType();
if (requestedTokenType == null) {
requestedTokenType = OAuth2Constants.ACCESS_TOKEN_TYPE;
return requestedTokenType;
}
if (getSupportedOAuthResponseTokenTypes().contains(requestedTokenType)) {
return requestedTokenType;
}
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);
}
protected Response exchangeExternalToken(String subjectIssuer, String subjectToken) {
// try to find the IDP whose alias matches the issuer or the subject issuer in the form params.
ExternalExchangeContext externalExchangeContext = this.locateExchangeExternalTokenByAlias(subjectIssuer);
if (externalExchangeContext == null) {
event.error(Errors.INVALID_ISSUER);
throw new CorsErrorResponseException(cors, Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST);
}
BrokeredIdentityContext context = externalExchangeContext.provider().exchangeExternal(this, this.context);
if (context == null) {
event.error(Errors.INVALID_ISSUER);
throw new CorsErrorResponseException(cors, Errors.INVALID_ISSUER, "Invalid " + OAuth2Constants.SUBJECT_ISSUER + " parameter", Response.Status.BAD_REQUEST);
}
UserModel user = importUserFromExternalIdentity(context);
UserSessionModel userSession = new UserSessionManager(session).createUserSession(realm, user, user.getUsername(), clientConnection.getRemoteHost(), "external-exchange", false, null, null);
externalExchangeContext.provider().exchangeExternalComplete(userSession, context, formParams);
// this must exist so that we can obtain access token from user session if idp's store tokens is off
userSession.setNote(IdentityProvider.EXTERNAL_IDENTITY_PROVIDER, externalExchangeContext.idpModel().getAlias());
userSession.setNote(IdentityProvider.FEDERATED_ACCESS_TOKEN, subjectToken);
context.addSessionNotesToUserSession(userSession);
return exchangeClientToClient(user, userSession, null, false);
}
}

View File

@@ -248,23 +248,26 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
clientSessionCtx.setAttribute(Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE);
TokenContextEncoderProvider encoder = session.getProvider(TokenContextEncoderProvider.class);
AccessTokenContext subjectTokenContext = encoder.getTokenContextFromTokenId(subjectToken.getId());
//copy subject client from the client session notes if the subject token used has already been exchanged
if (OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE.equals(subjectTokenContext.getGrantType())) {
ClientModel subjectClient = session.clients().getClientByClientId(realm, subjectToken.getIssuedFor());
if (subjectClient != null) {
AuthenticatedClientSessionModel subjectClientSession = targetUserSession.getAuthenticatedClientSessionByClient(subjectClient.getId());
if (subjectClientSession != null) {
subjectClientSession.getNotes().entrySet().stream()
.filter(note -> note.getKey().startsWith(Constants.TOKEN_EXCHANGE_SUBJECT_CLIENT))
.forEach(note -> clientSessionCtx.getClientSession().setNote(note.getKey(), note.getValue()));
if (subjectToken != null) {
AccessTokenContext subjectTokenContext = encoder.getTokenContextFromTokenId(subjectToken.getId());
//copy subject client from the client session notes if the subject token used has already been exchanged
if (OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE.equals(subjectTokenContext.getGrantType())) {
ClientModel subjectClient = session.clients().getClientByClientId(realm, subjectToken.getIssuedFor());
if (subjectClient != null) {
AuthenticatedClientSessionModel subjectClientSession = targetUserSession.getAuthenticatedClientSessionByClient(subjectClient.getId());
if (subjectClientSession != null) {
subjectClientSession.getNotes().entrySet().stream()
.filter(note -> note.getKey().startsWith(Constants.TOKEN_EXCHANGE_SUBJECT_CLIENT))
.forEach(note -> clientSessionCtx.getClientSession().setNote(note.getKey(), note.getValue()));
}
}
}
}
//store client id of the subject token
clientSessionCtx.getClientSession().setNote(Constants.TOKEN_EXCHANGE_SUBJECT_CLIENT + subjectToken.getIssuedFor(), subjectToken.getId());
//store client id of the subject token
clientSessionCtx.getClientSession().setNote(Constants.TOKEN_EXCHANGE_SUBJECT_CLIENT + subjectToken.getIssuedFor(), subjectToken.getId());
}
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session,
clientSessionCtx.getClientSession().getUserSession(), clientSessionCtx).generateAccessToken();

View File

@@ -54,11 +54,7 @@ import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation;
import org.keycloak.services.resources.admin.fgap.AdminPermissionManagement;
import org.keycloak.services.resources.admin.fgap.AdminPermissions;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
import org.keycloak.testsuite.broker.AbstractInitializedBaseBrokerTest;
import org.keycloak.testsuite.broker.BrokerConfiguration;
import org.keycloak.testsuite.broker.BrokerTestConstants;
@@ -84,8 +80,7 @@ import static org.keycloak.testsuite.broker.BrokerTestTools.getProviderRoot;
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
// TODO: Remove fine grained admin permissions should not be needed. They are neded now for token_exchange_external_internal:v2, but should not be needed in the future
@EnableFeatures({@EnableFeature(Profile.Feature.TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2), @EnableFeature(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)})
@EnableFeature(Profile.Feature.TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2)
public class ExternalInternalTokenExchangeV2Test extends AbstractInitializedBaseBrokerTest {
@Override
@@ -152,17 +147,6 @@ public class ExternalInternalTokenExchangeV2Test extends AbstractInitializedBase
client.setFullScopeAllowed(false);
client.setRedirectUris(Set.of(OAuthClient.AUTH_SERVER_ROOT + "/*"));
ClientModel brokerApp = realm.getClientByClientId("broker-app");
AdminPermissionManagement management = AdminPermissions.management(session, realm);
management.idps().setPermissionsEnabled(idp, true);
ClientPolicyRepresentation clientRep = new ClientPolicyRepresentation();
clientRep.setName("toIdp");
clientRep.addClient(client.getId(), brokerApp.getId());
ResourceServer server = management.realmResourceServer();
Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep);
management.idps().exchangeToPermission(idp).addAssociatedPolicy(clientPolicy);
realm = session.realms().getRealmByName(BrokerTestConstants.REALM_PROV_NAME);
client = realm.getClientByClientId("brokerapp");
client.addRedirectUri(OAuthClient.APP_ROOT + "/auth");